Compare commits

...
62 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 AmamiyaandGitHub 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
Ren Amamiya 1553f5ced2 bump version 2023-09-09 07:21:02 +07:00
Ren Amamiya 41901b2174 fix hashtag step in onboarding 2023-09-08 17:02:01 +07:00
Ren Amamiya 177e4c1ff7 fix some bugs 2023-09-08 15:29:41 +07:00
Ren Amamiya 10036500cb small fixes 2023-09-08 09:22:09 +07:00
Ren Amamiya a1fa777f8c fix logout function, prepare for multi-account support 2023-09-08 08:36:15 +07:00
Ren Amamiya 472925bb05 small fixes 2023-09-07 12:19:28 +07:00
Ren Amamiya 8eb11efb34 update notification 2023-09-07 11:34:26 +07:00
Ren Amamiya 48066a4018 bump version 2023-09-06 16:53:05 +07:00
Ren Amamiya 5c8850ea8f redesign widget list 2023-09-06 14:30:57 +07:00
Ren Amamiya 09aea3cff5 update widgets 2023-09-06 08:58:02 +07:00
Ren Amamiya 45c5a890b9 update tauri config 2023-09-06 07:46:31 +07:00
Ren Amamiya 69a3e85cb3 small updates and bump version 2023-09-05 17:25:00 +07:00
Ren Amamiya 224439f62b support drag and drop upload in composer 2023-09-05 13:07:21 +07:00
Ren Amamiya 2389ad5fdc replace void.cat with nostr.build 2023-09-05 12:46:00 +07:00
Ren Amamiya 4019623d99 small updates 2023-09-05 08:50:13 +07:00
Ren Amamiya 57c17ffbf9 use nostr.com to display unfound event 2023-09-04 18:09:41 +07:00
Ren Amamiya 98d2ccfc86 small fixes 2023-09-04 17:18:28 +07:00
Ren Amamiya c74a81cfdb re-add link preview 2023-09-04 14:35:57 +07:00
Ren Amamiya 3ebcf4a981 new parser, faster than before 50% 2023-09-04 14:05:04 +07:00
Ren Amamiya 5d45027776 fix build issue 2023-09-04 07:44:57 +07:00
Ren Amamiya 21ea8309c7 fix ci (final) 2023-09-03 12:02:59 +07:00
Ren Amamiya 431331174a fix ci again 2023-09-03 11:30:45 +07:00
Ren Amamiya c26cfc038d fix ci 2023-09-03 11:08:13 +07:00
Ren Amamiya 39b7b34bb7 new mention popup in composer 2023-09-03 08:43:08 +07:00
Ren Amamiya a4cf65e7c2 update ui consistent for cross platform 2023-09-03 07:43:38 +07:00
Ren Amamiya 37668393f1 expt: disable note metadata 2023-09-02 17:28:05 +07:00
Ren AmamiyaandGitHub 4309f734b6 Merge pull request #80 from luminous-devs/revert-to-tauri-v1
Revert to Tauri v1.4.0
2023-09-02 12:49:51 +07:00
Ren Amamiya b4957bae1f native fetch and shadow 2023-09-02 12:49:04 +07:00
Ren Amamiya 7a3b19bf7b revert to tauri v1 2023-09-02 12:15:48 +07:00
Ren Amamiya 1931373515 update composer 2023-09-02 08:50:54 +07:00
Ren Amamiya 28939d1733 improve hashtag parser 2023-09-01 18:26:22 +07:00
Ren Amamiya e6d35bc635 fix mention in composer and improve error handling 2023-09-01 15:57:31 +07:00
Ren Amamiya cc315a190a fully support nip05 2023-09-01 08:58:33 +07:00
153 changed files with 5707 additions and 4942 deletions
+4 -3
View File
@@ -1,4 +1,4 @@
name: 'publish' name: 'Publish'
on: workflow_dispatch on: workflow_dispatch
env: env:
@@ -24,7 +24,7 @@ jobs:
- name: setup node - name: setup node
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 18
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
targets: aarch64-apple-darwin targets: aarch64-apple-darwin
@@ -32,7 +32,7 @@ jobs:
if: matrix.settings.platform == 'ubuntu-22.04' if: matrix.settings.platform == 'ubuntu-22.04'
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libglib2.0-dev libsoup-3.0 libgtk-3-dev webkit2gtk-4.1 javascriptcoregtk-4.1 libappindicator3-dev librsvg2-dev patchelf sudo apt-get install -y build-essential libssl-dev libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
with: with:
@@ -66,3 +66,4 @@ jobs:
releaseDraft: true releaseDraft: true
prerelease: false prerelease: false
args: ${{ matrix.settings.args }} args: ${{ matrix.settings.args }}
includeDebug: true
+1 -1
View File
@@ -14,9 +14,9 @@ out
*.local *.local
.next .next
.vscode .vscode
pnpm-lock.yaml
*.db *.db
*.db-journal *.db-journal
bun.lockb
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
+12 -12
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 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 ### Develop
Clone project Clone project
@@ -19,23 +25,17 @@ git clone https://github.com/luminous-devs/lume.git && cd lume
Install packages Install packages
``` ```
pnpm install bun install
``` ```
Run dev Run dev build
``` ```
pnpm tauri dev bun tauri dev
``` ```
Build Generate production build
``` ```
pnpm tauri build bun tauri build
``` ```
(Advance) - Generate SQLite migration
```
pnpm add-migrate <migrate_name>
```
+31 -44
View File
@@ -2,7 +2,7 @@
"name": "lume", "name": "lume",
"description": "the communication app", "description": "the communication app",
"private": true, "private": true,
"version": "1.2.1", "version": "1.2.5",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
@@ -18,8 +18,8 @@
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write" "**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
}, },
"dependencies": { "dependencies": {
"@ctrl/magnet-link": "^3.1.2", "@getalby/sdk": "^2.4.0",
"@nostr-dev-kit/ndk": "^0.8.23", "@nostr-dev-kit/ndk": "^1.0.0",
"@nostr-fetch/adapter-ndk": "^0.12.2", "@nostr-fetch/adapter-ndk": "^0.12.2",
"@radix-ui/react-alert-dialog": "^1.0.4", "@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-collapsible": "^1.0.3",
@@ -27,33 +27,16 @@
"@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-popover": "^1.0.6", "@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6", "@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-query": "^4.33.0", "@tanstack/react-query": "^4.35.0",
"@tanstack/react-virtual": "3.0.0-beta.54", "@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "2.0.0-alpha.6", "@tauri-apps/api": "^1.4.0",
"@tauri-apps/cli": "2.0.0-alpha.11", "@tiptap/extension-image": "^2.1.8",
"@tauri-apps/plugin-app": "github:tauri-apps/tauri-plugin-app#v2", "@tiptap/extension-mention": "^2.1.8",
"@tauri-apps/plugin-autostart": "github:tauri-apps/tauri-plugin-autostart#v2", "@tiptap/extension-placeholder": "^2.1.8",
"@tauri-apps/plugin-clipboard-manager": "github:tauri-apps/tauri-plugin-clipboard-manager#v2", "@tiptap/pm": "^2.1.8",
"@tauri-apps/plugin-dialog": "github:tauri-apps/tauri-plugin-dialog#v2", "@tiptap/react": "^2.1.8",
"@tauri-apps/plugin-fs": "github:tauri-apps/tauri-plugin-fs#v2", "@tiptap/starter-kit": "^2.1.8",
"@tauri-apps/plugin-http": "github:tauri-apps/tauri-plugin-http#v2", "@tiptap/suggestion": "^2.1.8",
"@tauri-apps/plugin-notification": "github:tauri-apps/tauri-plugin-notification#v2",
"@tauri-apps/plugin-os": "github:tauri-apps/tauri-plugin-os#v2",
"@tauri-apps/plugin-process": "github:tauri-apps/tauri-plugin-process#v2",
"@tauri-apps/plugin-shell": "github:tauri-apps/tauri-plugin-shell#v2",
"@tauri-apps/plugin-sql": "github:tauri-apps/tauri-plugin-sql#v2",
"@tauri-apps/plugin-store": "github:tauri-apps/tauri-plugin-store#v2",
"@tauri-apps/plugin-stronghold": "github:tauri-apps/tauri-plugin-stronghold#v2",
"@tauri-apps/plugin-upload": "github:tauri-apps/tauri-plugin-upload#v2",
"@tauri-apps/plugin-window": "github:tauri-apps/tauri-plugin-window#v2",
"@tiptap/extension-image": "^2.1.7",
"@tiptap/extension-mention": "^2.1.7",
"@tiptap/extension-placeholder": "^2.1.7",
"@tiptap/pm": "^2.1.7",
"@tiptap/react": "^2.1.7",
"@tiptap/starter-kit": "^2.1.7",
"@tiptap/suggestion": "^2.1.7",
"@void-cat/api": "^1.0.7",
"dayjs": "^1.11.9", "dayjs": "^1.11.9",
"destr": "^2.0.1", "destr": "^2.0.1",
"get-urls": "^12.1.0", "get-urls": "^12.1.0",
@@ -61,53 +44,57 @@
"light-bolt11-decoder": "^3.0.0", "light-bolt11-decoder": "^3.0.0",
"lru-cache": "^10.0.1", "lru-cache": "^10.0.1",
"minidenticons": "^4.2.0", "minidenticons": "^4.2.0",
"nostr-fetch": "^0.12.2", "nostr-fetch": "^0.13.0",
"nostr-tools": "^1.14.2", "nostr-tools": "^1.14.2",
"qrcode.react": "^3.1.0", "qrcode.react": "^3.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-currency-input-field": "^3.6.11",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.45.4", "react-hook-form": "^7.46.1",
"react-hotkeys-hook": "^4.4.1",
"react-markdown": "^8.0.7", "react-markdown": "^8.0.7",
"react-player": "^2.12.0", "react-player": "^2.13.0",
"react-router-dom": "^6.15.0", "react-router-dom": "^6.15.0",
"react-textarea-autosize": "^8.5.3", "react-textarea-autosize": "^8.5.3",
"react-virtuoso": "^4.5.0", "react-virtuoso": "^4.5.1",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"tippy.js": "^6.3.7", "tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql#v1",
"tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1",
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1",
"tauri-plugin-upload-api": "github:tauri-apps/tauri-plugin-upload#v1",
"zustand": "^4.4.1" "zustand": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.10",
"@tauri-apps/cli": "^1.4.0",
"@trivago/prettier-plugin-sort-imports": "^4.2.0", "@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/html-to-text": "^9.0.1", "@types/html-to-text": "^9.0.1",
"@types/node": "^20.5.6", "@types/node": "^20.6.0",
"@types/react": "^18.2.21", "@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/youtube-player": "^5.5.7", "@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.4.1", "@typescript-eslint/parser": "^6.6.0",
"@vitejs/plugin-react-swc": "^3.3.2", "@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15", "autoprefixer": "^10.4.15",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"csstype": "^3.1.2", "csstype": "^3.1.2",
"encoding": "^0.1.13", "encoding": "^0.1.13",
"eslint": "^8.48.0", "eslint": "^8.49.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3", "husky": "^8.0.3",
"lint-staged": "^14.0.1", "lint-staged": "^14.0.1",
"postcss": "^8.4.28", "postcss": "^8.4.29",
"prettier": "^3.0.2", "prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.3", "prettier-plugin-tailwindcss": "^0.5.4",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^4.4.9", "vite": "^4.4.9",
"vite-tsconfig-paths": "^4.2.0" "vite-tsconfig-paths": "^4.2.1"
} }
} }
+1277 -2170
View File
File diff suppressed because it is too large Load Diff
+737 -921
View File
File diff suppressed because it is too large Load Diff
+37 -29
View File
@@ -1,51 +1,59 @@
[package] [package]
name = "lume" name = "lume"
version = "1.2.1" version = "1.2.5"
description = "nostr client" description = "the communication app"
authors = ["Ren Amamiya"] authors = ["Ren Amamiya"]
license = "GPL-3.0" license = "GPL-3.0"
repository = "https://github.com/luminous-devs/lume" repository = "https://github.com/luminous-devs/lume"
edition = "2021" edition = "2021"
rust-version = "1.71" rust-version = "1.66"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.0.0-alpha.8", features = [] } tauri-build = { version = "1.4", features = [] }
[dependencies] [dependencies]
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
tauri = { git = "https://git.nextgraph.org/NextGraph/tauri.git", branch = "alpha.11-nextgraph", features = [ tauri = { version = "1.4", features = [
"protocol-asset", "window-close",
"window-print",
"window-create",
"macos-private-api", "macos-private-api",
"no-ipc-custom-protocol", "fs-read-dir",
"fs-read-file",
"window-start-dragging",
"path-all",
"http-all",
"clipboard-write-text",
"os-all",
"notification-all",
"clipboard-read-text",
"window-set-resizable",
"window-set-size",
"shell-open",
"fs-write-file",
"app-all",
"fs-remove-file",
"window-center",
"dialog-all",
"http-multipart",
] } ] }
tauri-plugin-single-instance = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" } tauri-plugin-sql = { git = "hhttps://github.com/tauri-apps/plugins-workspace", branch = "v1", features = [
tauri-plugin-autostart = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" } "sqlite",
tauri-plugin-stronghold = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" } ] }
tauri-plugin-upload = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" } tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-updater = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" } tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-dialog = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" } tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-http = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" } tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-fs = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" } tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-clipboard-manager = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" } window-vibrancy = { git = "https://github.com/tauri-apps/window-vibrancy", branch = "dev" }
tauri-plugin-notification = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-app = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-process = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-os = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-window = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-store = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-shell = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
sqlx-cli = { version = "0.7.0", default-features = false, features = [ sqlx-cli = { version = "0.7.0", default-features = false, features = [
"sqlite", "sqlite",
] } ] }
rust-argon2 = "1.0" rust-argon2 = "1.0"
webpage = { version = "1.6.0", features = ["serde"] }
[dependencies.tauri-plugin-sql]
git = "https://github.com/luminous-devs/plugins-workspace"
branch = "v2"
features = ["sqlite"]
[features] [features]
# by default Tauri runs in production mode # by default Tauri runs in production mode
@@ -53,7 +61,7 @@ features = ["sqlite"]
default = ["custom-protocol"] default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem # this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this # DO NOT remove this
custom-protocol = [] custom-protocol = ["tauri/custom-protocol"]
# Optimized for bundle size. If you want faster builds comment out/delete this section. # Optimized for bundle size. If you want faster builds comment out/delete this section.
[profile.release] [profile.release]
@@ -0,0 +1,2 @@
-- Add migration script here
CREATE UNIQUE INDEX unique_relay ON relays (relay);
+86 -12
View File
@@ -6,6 +6,11 @@
use tauri::Manager; use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_sql::{Migration, MigrationKind}; use tauri_plugin_sql::{Migration, MigrationKind};
use webpage::{Webpage, WebpageOptions};
use std::time::Duration;
#[cfg(target_os = "macos")]
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
#[derive(Clone, serde::Serialize)] #[derive(Clone, serde::Serialize)]
struct Payload { struct Payload {
@@ -13,6 +18,71 @@ struct Payload {
cwd: String, cwd: String,
} }
#[derive(serde::Serialize)]
struct OpenGraphResponse {
title: String,
description: String,
url: String,
image: String,
}
async fn fetch_opengraph(url: String) -> OpenGraphResponse {
let options = WebpageOptions {
allow_insecure: false,
max_redirections: 3,
timeout: Duration::from_secs(15),
useragent: "lume - desktop app".to_string(),
..Default::default()
};
let result = match Webpage::from_url(&url, options) {
Ok(webpage) => webpage,
Err(_) => {
return OpenGraphResponse {
title: "".to_string(),
description: "".to_string(),
url: "".to_string(),
image: "".to_string(),
}
}
};
let html = result.html;
return OpenGraphResponse {
title: html
.opengraph
.properties
.get("title")
.cloned()
.unwrap_or_default(),
description: html
.opengraph
.properties
.get("description")
.cloned()
.unwrap_or_default(),
url: html
.opengraph
.properties
.get("url")
.cloned()
.unwrap_or_default(),
image: html
.opengraph
.images
.get(0)
.and_then(|i| Some(i.url.clone()))
.unwrap_or_default(),
};
}
#[tauri::command]
async fn opengraph(url: String) -> OpenGraphResponse {
let result = fetch_opengraph(url).await;
return result;
}
#[tauri::command] #[tauri::command]
async fn close_splashscreen(window: tauri::Window) { async fn close_splashscreen(window: tauri::Window) {
// Close splashscreen // Close splashscreen
@@ -25,6 +95,15 @@ async fn close_splashscreen(window: tauri::Window) {
fn main() { fn main() {
tauri::Builder::default() tauri::Builder::default()
.setup(|app| {
let window = app.get_window("main").unwrap();
#[cfg(target_os = "macos")]
apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, None)
.expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS");
Ok(())
})
.plugin( .plugin(
tauri_plugin_sql::Builder::default() tauri_plugin_sql::Builder::default()
.add_migrations( .add_migrations(
@@ -132,6 +211,12 @@ fn main() {
sql: include_str!("../migrations/20230817014932_add_last_login_time_to_account.sql"), sql: include_str!("../migrations/20230817014932_add_last_login_time_to_account.sql"),
kind: MigrationKind::Up, 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(), .build(),
@@ -168,20 +253,9 @@ fn main() {
.emit_all("single-instance", Payload { args: argv, cwd }) .emit_all("single-instance", Payload { args: argv, cwd })
.unwrap(); .unwrap();
})) }))
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_upload::init()) .plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_app::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_window::init())
.plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_shell::init()) .invoke_handler(tauri::generate_handler![close_splashscreen, opengraph])
.invoke_handler(tauri::generate_handler![close_splashscreen])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
+86 -42
View File
@@ -1,47 +1,86 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"build": { "build": {
"beforeBuildCommand": "pnpm build", "beforeBuildCommand": "pnpm run build",
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm run dev",
"devPath": "http://localhost:3000", "devPath": "http://localhost:3000",
"distDir": "../dist", "distDir": "../dist",
"withGlobalTauri": true "withGlobalTauri": true
}, },
"package": { "package": {
"productName": "Lume", "productName": "Lume",
"version": "1.2.1" "version": "1.2.5"
},
"plugins": {
"fs": {
"scope": [
"$APPDATA/*",
"$DATA/*",
"$LOCALDATA/*",
"$DESKTOP/*",
"$DOCUMENT/*",
"$DOWNLOAD/*",
"$HOME/*",
"$PICTURE/*",
"$PUBLIC/*",
"$VIDEO/*"
]
},
"http": {
"scope": [
"http://**/",
"https://**/"
]
},
"shell": {
"open": true
},
"updater": {
"endpoints": [
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
]
}
}, },
"tauri": { "tauri": {
"allowlist": {
"app": {
"all": true,
"show": true,
"hide": true
},
"path": {
"all": true
},
"dialog": {
"all": true,
"ask": true,
"confirm": true,
"message": true,
"open": true,
"save": true
},
"fs": {
"all": false,
"removeFile": true,
"writeFile": true,
"readDir": true,
"readFile": true,
"scope": [
"$APPDATA/*",
"$DATA/*",
"$LOCALDATA/*",
"$DESKTOP/*",
"$DOCUMENT/*",
"$DOWNLOAD/*",
"$HOME/*",
"$PICTURE/*",
"$PUBLIC/*",
"$VIDEO/*"
]
},
"http": {
"all": true,
"scope": [
"http://**",
"https://**"
]
},
"shell": {
"all": false,
"open": true
},
"os": {
"all": true
},
"window": {
"all": false,
"center": true,
"setResizable": true,
"setSize": true,
"startDragging": true,
"create": true,
"close": true,
"print": true
},
"clipboard": {
"all": false,
"writeText": true,
"readText": true
},
"notification": {
"all": true
}
},
"bundle": { "bundle": {
"active": true, "active": true,
"appimage": { "appimage": {
@@ -79,19 +118,24 @@
"timestampUrl": "" "timestampUrl": ""
} }
}, },
"updater": {
"endpoints": [
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
]
},
"security": { "security": {
"csp": { "csp": {
"connect-src": "ipc: https://ipc.localhost",
"content-security-policy": "upgrade-insecure-requests" "content-security-policy": "upgrade-insecure-requests"
}, },
"freezePrototype": false, "dangerousRemoteDomainIpcAccess": [
"assetProtocol": { {
"enable": true, "scheme": "https",
"scope": { "domain": "nwc.getalby.com",
"allow": ["$APPCONFIG/*.db", "$RESOURCE/**"], "windows": ["alby"],
"deny": ["$APPCONFIG/*.stronghold"] "enableTauriAPI": true
} }
} ]
}, },
"macOSPrivateApi": true "macOSPrivateApi": true
} }
+1
View File
@@ -1,4 +1,5 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": { "tauri": {
"windows": [ "windows": [
{ {
+2 -5
View File
@@ -1,4 +1,5 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": { "tauri": {
"windows": [ "windows": [
{ {
@@ -27,11 +28,7 @@
"fullscreen": false, "fullscreen": false,
"hiddenTitle": true, "hiddenTitle": true,
"visible": false, "visible": false,
"fileDropEnabled": true, "fileDropEnabled": true
"windowEffects": {
"effects": ["hudWindow"],
"state": "followsWindowActiveState"
}
} }
] ]
} }
+3 -6
View File
@@ -1,4 +1,5 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": { "tauri": {
"windows": [ "windows": [
{ {
@@ -19,16 +20,12 @@
"resizable": true, "resizable": true,
"theme": "Dark", "theme": "Dark",
"title": "Lume", "title": "Lume",
"transparent": true, "transparent": false,
"center": true, "center": true,
"fullscreen": false, "fullscreen": false,
"hiddenTitle": true, "hiddenTitle": true,
"visible": false, "visible": false,
"fileDropEnabled": true, "fileDropEnabled": true
"windowEffects": {
"effects": ["micaDark", "micaLight", "acrylic"],
"state": "followsWindowActiveState"
}
} }
] ]
} }
+25 -21
View File
@@ -1,3 +1,4 @@
import { message } from '@tauri-apps/api/dialog';
import { RouterProvider, createBrowserRouter, redirect } from 'react-router-dom'; import { RouterProvider, createBrowserRouter, redirect } from 'react-router-dom';
import { AuthCreateScreen } from '@app/auth/create'; import { AuthCreateScreen } from '@app/auth/create';
@@ -5,6 +6,7 @@ import { AuthImportScreen } from '@app/auth/import';
import { OnboardingScreen } from '@app/auth/onboarding'; import { OnboardingScreen } from '@app/auth/onboarding';
import { ErrorScreen } from '@app/error'; import { ErrorScreen } from '@app/error';
import { Frame } from '@shared/frame';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { AppLayout } from '@shared/layouts/app'; import { AppLayout } from '@shared/layouts/app';
import { AuthLayout } from '@shared/layouts/auth'; import { AuthLayout } from '@shared/layouts/auth';
@@ -18,18 +20,20 @@ import './index.css';
async function Loader() { async function Loader() {
try { try {
const account = await checkActiveAccount(); const account = await checkActiveAccount();
const stronghold = sessionStorage.getItem('stronghold'); const stronghold = sessionStorage.getItem('stronghold');
const privkey = JSON.parse(stronghold).state.privkey || null; const privkey = JSON.parse(stronghold).state.privkey || null;
const onboarding = localStorage.getItem('onboarding'); const onboarding = localStorage.getItem('onboarding');
const step = JSON.parse(onboarding).state.step || null; const step = JSON.parse(onboarding).state.step || null;
if (step) {
return redirect(step);
}
if (!account) { if (!account) {
return redirect('/auth/welcome'); return redirect('/auth/welcome');
} else { } else {
if (step) {
return redirect(step);
}
if (!privkey) { if (!privkey) {
return redirect('/auth/unlock'); return redirect('/auth/unlock');
} }
@@ -37,7 +41,7 @@ async function Loader() {
return null; return null;
} catch (e) { } catch (e) {
throw new Error('App failed to load'); await message(e, { title: 'An unexpected error has occurred', type: 'error' });
} }
} }
@@ -76,6 +80,13 @@ const router = createBrowserRouter([
return { Component: NotificationScreen }; return { Component: NotificationScreen };
}, },
}, },
{
path: 'nwc',
async lazy() {
const { NWCScreen } = await import('@app/nwc');
return { Component: NWCScreen };
},
},
], ],
}, },
{ {
@@ -194,15 +205,15 @@ const router = createBrowserRouter([
return { Component: OnboardStep2Screen }; 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', path: 'unlock',
async lazy() { async lazy() {
@@ -224,13 +235,6 @@ const router = createBrowserRouter([
return { Component: ResetScreen }; return { Component: ResetScreen };
}, },
}, },
{
path: 'hard-reset',
async lazy() {
const { HardResetScreen } = await import('@app/auth/hardReset');
return { Component: HardResetScreen };
},
},
], ],
}, },
{ {
@@ -261,9 +265,9 @@ export default function App() {
<RouterProvider <RouterProvider
router={router} router={router}
fallbackElement={ fallbackElement={
<div className="flex h-full w-full items-center justify-center bg-black/90 backdrop-blur-xl"> <Frame className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-6 w-6 animate-spin text-white" /> <LoaderIcon className="h-6 w-6 animate-spin text-white" />
</div> </Frame>
} }
/> />
); );
+42
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>
);
}
-38
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 || user?.nip05}
</p>
<span className="max-w-[15rem] truncate leading-tight text-white/50">
{displayNpub(pubkey, 16)}
</span>
</div>
</div>
);
}
-36
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 || user?.nip05 || displayNpub(pubkey, 16)}
</span>
</div>
</div>
);
}
+87 -82
View File
@@ -1,13 +1,14 @@
import { BaseDirectory, writeTextFile } from '@tauri-apps/plugin-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 { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { Button } from '@shared/button'; import { CopyIcon } from '@shared/icons';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding'; import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold'; import { useStronghold } from '@stores/stronghold';
@@ -21,8 +22,8 @@ export function CreateStep1Screen() {
const setPubkey = useOnboarding((state) => state.setPubkey); const setPubkey = useOnboarding((state) => state.setPubkey);
const setStep = useOnboarding((state) => state.setStep); const setStep = useOnboarding((state) => state.setStep);
const [privkeyInput, setPrivkeyInput] = useState('password');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [downloaded, setDownloaded] = useState(false); const [downloaded, setDownloaded] = useState(false);
const privkey = useMemo(() => generatePrivateKey(), []); const privkey = useMemo(() => generatePrivateKey(), []);
@@ -30,27 +31,39 @@ export function CreateStep1Screen() {
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey); const nsec = nip19.nsecEncode(privkey);
// toggle private key const download = async () => {
const showPrivateKey = () => { try {
if (privkeyInput === 'password') { const downloadPath = await downloadDir();
setPrivkeyInput('text'); const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
} else { const filePath = await save({
setPrivkeyInput('password'); 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 () => { const copyPrivkey = async () => {
await writeTextFile( try {
`nostr_keys_${new Date().toISOString().slice(0, 10)}.txt`, await writeText(nsec);
`Generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`, setCopied(true);
{
dir: BaseDirectory.Download, setTimeout(() => setCopied(false), 3000);
} } catch (e) {
); await message(e, { title: 'Cannot copy private key', type: 'error' });
setDownloaded(true); }
}; };
const submit = () => { const submit = async () => {
setLoading(true); setLoading(true);
// update state // update state
@@ -59,7 +72,7 @@ export function CreateStep1Screen() {
setPubkey(pubkey); setPubkey(pubkey);
// save to database // save to database
db.createAccount(npub, pubkey); await db.createAccount(npub, pubkey);
// redirect to next step // redirect to next step
navigate('/auth/create/step-2', { replace: true }); navigate('/auth/create/step-2', { replace: true });
@@ -67,81 +80,73 @@ export function CreateStep1Screen() {
useEffect(() => { useEffect(() => {
// save current step, if user close app and reopen it // save current step, if user close app and reopen it
setStep('/auth/create/step-1'); setStep('/auth/create');
}, []); }, []);
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 border-b border-white/10 pb-4">
<h1 className="text-xl font-semibold text-white">Save your access key!</h1> <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>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-8">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-3">
<span className="text-base font-semibold text-white/50">Public Key</span> <div className="flex flex-col gap-1">
<input <span className="font-medium text-white">Private Key</span>
readOnly <div className="relative">
value={npub} <input
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" readOnly
/> value={nsec.substring(0, 5) + '**************************************'}
</div> 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"
<div className="flex flex-col gap-1"> />
<span className="text-base font-semibold text-white/50">Private Key</span> <button
<div className="relative"> 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 <input
readOnly readOnly
type={privkeyInput} value={npub}
value={nsec} 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"
className="relative h-11 w-full rounded-lg bg-white/10 py-1 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
/> />
<button
type="button"
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 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> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<button <button
type="button" type="button"
onClick={() => submit()} onClick={() => download()}
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-center rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
> >
{loading ? ( {downloaded ? 'Downloaded' : 'Download account keys'}
<>
<span className="w-5" />
<span>Creating...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>I have saved my key, continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button> </button>
{downloaded ? ( <button
<span className="inline-flex h-11 w-full items-center justify-center text-sm text-white/50"> type="button"
Saved in Download folder onClick={() => submit()}
</span> 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"
) : ( >
<Button preset="large-alt" onClick={() => download()}> {loading ? 'Creating...' : 'Continue'}
Download </button>
</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> </div>
</div> </div>
+16 -16
View File
@@ -1,8 +1,8 @@
import { appConfigDir } from '@tauri-apps/api/path'; import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form'; import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
@@ -61,9 +61,9 @@ export function CreateStep2Screen() {
setLoading(true); setLoading(true);
if (data.password.length > 3) { if (data.password.length > 3) {
const dir = await appConfigDir(); const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password); const stronghold = await Stronghold.load(`${dir}lume.stronghold`, data.password);
db.secureDB = stronghold; if (!db.secureDB) db.secureDB = stronghold;
// save privkey to secure storage // save privkey to secure storage
await db.secureSave(pubkey, privkey); await db.secureSave(pubkey, privkey);
@@ -86,10 +86,16 @@ export function CreateStep2Screen() {
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 border-b border-white/10 pb-4">
<h1 className="text-xl font-semibold text-white"> <h1 className="mb-2 text-center text-2xl font-semibold text-white">
Set password to secure your key Set password to secure your key
</h1> </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>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
@@ -98,12 +104,13 @@ export function CreateStep2Screen() {
<input <input
{...register('password', { required: true })} {...register('password', { required: true })}
type={passwordInput} 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 <button
type="button" type="button"
onClick={() => showPassword()} onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 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' ? ( {passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" /> <EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
@@ -112,13 +119,6 @@ export function CreateStep2Screen() {
)} )}
</button> </button>
</div> </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"> <span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>} {errors.password && <p>{errors.password.message}</p>}
</span> </span>
@@ -127,12 +127,12 @@ export function CreateStep2Screen() {
<button <button
type="submit" type="submit"
disabled={!isDirty || !isValid} disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-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 ? ( {loading ? (
<> <>
<span className="w-5" /> <span className="w-5" />
<span>Creating...</span> <span>Securing your account...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" /> <LoaderIcon className="h-5 w-5 animate-spin text-white" />
</> </>
) : ( ) : (
+38 -29
View File
@@ -1,7 +1,10 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader'; import { AvatarUploader } from '@shared/avatarUploader';
import { BannerUploader } from '@shared/bannerUploader'; import { BannerUploader } from '@shared/bannerUploader';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
@@ -9,6 +12,7 @@ import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { Image } from '@shared/image'; import { Image } from '@shared/image';
import { useOnboarding } from '@stores/onboarding'; import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
@@ -20,6 +24,7 @@ export function CreateStep3Screen() {
const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih'); const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
const [banner, setBanner] = useState(''); const [banner, setBanner] = useState('');
const { db } = useStorage();
const { publish } = useNostr(); const { publish } = useNostr();
const { const {
register, register,
@@ -40,10 +45,13 @@ export function CreateStep3Screen() {
const event = await publish({ const event = await publish({
content: JSON.stringify(profile), content: JSON.stringify(profile),
kind: 0, kind: NDKKind.Metadata,
tags: [], tags: [],
}); });
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
if (event) { if (event) {
navigate('/auth/onboarding', { replace: true }); navigate('/auth/onboarding', { replace: true });
} }
@@ -60,30 +68,41 @@ export function CreateStep3Screen() {
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 border-b border-white/10 pb-4">
<h1 className="text-xl font-semibold text-white">Create your profile</h1> <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>
<div className="w-full overflow-hidden rounded-xl bg-white/10 backdrop-blur-xl"> <div className="w-full overflow-hidden rounded-xl bg-white/10 backdrop-blur-xl">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col"> <form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<input type={'hidden'} {...register('picture')} value={picture} /> <input type={'hidden'} {...register('picture')} value={picture} />
<input type={'hidden'} {...register('banner')} value={banner} /> <input type={'hidden'} {...register('banner')} value={banner} />
<div className="relative"> <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">
<Image {banner ? (
src={banner} <Image
alt="user's banner" src={banner}
className="h-full w-full object-cover" alt="user's banner"
/> className="h-full w-full object-cover"
/>
) : (
<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"> <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} /> <BannerUploader setBanner={setBanner} />
</div> </div>
</div> </div>
<div className="mb-5 px-4"> <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 <Image
src={picture} src={picture}
alt="user's avatar" 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"> <div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<AvatarUploader setPicture={setPicture} /> <AvatarUploader setPicture={setPicture} />
@@ -93,55 +112,45 @@ export function CreateStep3Screen() {
</div> </div>
<div className="flex flex-col gap-4 px-4 pb-4"> <div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label <label htmlFor="name" className="font-medium text-white">
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
Name * Name *
</label> </label>
<input <input
type={'text'} type={'text'}
{...register('name', { {...register('name', {
required: true, required: true,
minLength: 4, minLength: 1,
})} })}
spellCheck={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> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label <label htmlFor="about" className="font-medium text-white">
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
Bio Bio
</label> </label>
<textarea <textarea
{...register('about')} {...register('about')}
spellCheck={false} spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-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>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label <label htmlFor="website" className="font-medium text-white">
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
Website Website
</label> </label>
<input <input
type={'text'}
{...register('website', { {...register('website', {
required: false, required: false,
})} })}
spellCheck={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> </div>
<button <button
type="submit" type="submit"
disabled={!isDirty || !isValid} 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 ? ( {loading ? (
<> <>
-7
View File
@@ -1,7 +0,0 @@
export function HardResetScreen() {
return (
<div>
<p>hard reset</p>
</div>
);
}
+45 -17
View File
@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider'; 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 { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding'; import { useOnboarding } from '@stores/onboarding';
@@ -37,6 +37,7 @@ export function ImportStep1Screen() {
const setStep = useOnboarding((state) => state.setStep); const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [passwordInput, setPasswordInput] = useState('password');
const { db } = useStorage(); const { db } = useStorage();
const { const {
@@ -64,12 +65,13 @@ export function ImportStep1Screen() {
setPubkey(pubkey); setPubkey(pubkey);
// add account to local database // add account to local database
db.createAccount(npub, pubkey); await db.createAccount(npub, pubkey);
// redirect to step 2 // redirect to step 2 with delay 1.2s
navigate('/auth/import/step-2', { replace: true }); setTimeout(() => navigate('/auth/import/step-2', { replace: true }), 1200);
} }
} catch (error) { } catch (error) {
setLoading(false);
setError('privkey', { setError('privkey', {
type: 'custom', type: 'custom',
message: 'Private key is invalid, please check again', message: 'Private key is invalid, please check again',
@@ -77,27 +79,53 @@ export function ImportStep1Screen() {
} }
}; };
// toggle private key
const showPassword = () => {
if (passwordInput === 'password') {
setPasswordInput('text');
} else {
setPasswordInput('password');
}
};
useEffect(() => { useEffect(() => {
// save current step, if user close app and reopen it // save current step, if user close app and reopen it
setStep('/auth/import/step-1'); setStep('/auth/import');
}, []); }, []);
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 pb-4">
<h1 className="text-xl font-semibold text-white">Import your key</h1> <h1 className="text-center text-2xl font-semibold text-white">
Import your Nostr key
</h1>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3"> <form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-base font-semibold text-white/50">Private key</span> <label htmlFor="privkey" className="font-medium text-white">
<input Insert your nostr private key, in nsec or hex format
{...register('privkey', { required: true, minLength: 32 })} </label>
type={'password'} <div className="relative">
placeholder="nsec or hexstring" <input
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" {...register('privkey', { required: true, minLength: 32 })}
/> type={passwordInput}
<span className="text-sm text-red-400"> 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>} {errors.privkey && <p>{errors.privkey.message}</p>}
</span> </span>
</div> </div>
@@ -105,12 +133,12 @@ export function ImportStep1Screen() {
<button <button
type="submit" type="submit"
disabled={!isDirty || !isValid} disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-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 ? ( {loading ? (
<> <>
<span className="w-5" /> <span className="w-5" />
<span>Creating...</span> <span>Importing...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" /> <LoaderIcon className="h-5 w-5 animate-spin text-white" />
</> </>
) : ( ) : (
+16 -14
View File
@@ -1,8 +1,8 @@
import { appConfigDir } from '@tauri-apps/api/path'; import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form'; import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
@@ -61,9 +61,9 @@ export function ImportStep2Screen() {
setLoading(true); setLoading(true);
if (data.password.length > 3) { if (data.password.length > 3) {
const dir = await appConfigDir(); const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password); const stronghold = await Stronghold.load(`${dir}lume.stronghold`, data.password);
db.secureDB = stronghold; if (!db.secureDB) db.secureDB = stronghold;
// save privkey to secure storage // save privkey to secure storage
await db.secureSave(pubkey, privkey); await db.secureSave(pubkey, privkey);
@@ -86,10 +86,16 @@ export function ImportStep2Screen() {
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 border-b border-white/10 pb-4">
<h1 className="text-xl font-semibold text-white"> <h1 className="mb-2 text-center text-2xl font-semibold text-white">
Set password to secure your key Set password to secure your key
</h1> </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>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
@@ -98,12 +104,13 @@ export function ImportStep2Screen() {
<input <input
{...register('password', { required: true })} {...register('password', { required: true })}
type={passwordInput} 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 <button
type="button" type="button"
onClick={() => showPassword()} onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 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' ? ( {passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" /> <EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
@@ -112,11 +119,6 @@ export function ImportStep2Screen() {
)} )}
</button> </button>
</div> </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"> <span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>} {errors.password && <p>{errors.password.message}</p>}
</span> </span>
@@ -125,12 +127,12 @@ export function ImportStep2Screen() {
<button <button
type="submit" type="submit"
disabled={!isDirty || !isValid} disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-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 ? ( {loading ? (
<> <>
<span className="w-5" /> <span className="w-5" />
<span>Creating...</span> <span>Securing your account...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" /> <LoaderIcon className="h-5 w-5 animate-spin text-white" />
</> </>
) : ( ) : (
+20 -11
View File
@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding'; import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
@@ -15,11 +15,11 @@ export function ImportStep3Screen() {
const navigate = useNavigate(); const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep); const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
const { db } = useStorage(); const { db } = useStorage();
const { fetchUserData, prefetchEvents } = useNostr(); const { fetchUserData, prefetchEvents } = useNostr();
const [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
try { try {
// show loading indicator // show loading indicator
@@ -29,6 +29,9 @@ export function ImportStep3Screen() {
const user = await fetchUserData(); const user = await fetchUserData();
const data = await prefetchEvents(); const data = await prefetchEvents();
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
// redirect to next step // redirect to next step
if (user.status === 'ok' && data.status === 'ok') { if (user.status === 'ok' && data.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true }); navigate('/auth/onboarding/step-2', { replace: true });
@@ -49,17 +52,19 @@ export function ImportStep3Screen() {
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 pb-4">
<h1 className="text-xl font-semibold"> <h1 className="text-center text-2xl font-semibold text-white">
{loading ? 'Prefetching data...' : 'Continue with'} {loading ? 'Downloading...' : 'Your Nostr profile'}
</h1> </h1>
</div> </div>
<div className="w-full rounded-xl bg-white/10 p-4 backdrop-blur-xl"> <div className="flex flex-col gap-3">
<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} /> <User pubkey={db.account.pubkey} variant="simple" />
</div>
<div className="flex flex-col gap-2">
<button <button
type="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()} onClick={() => submit()}
> >
{loading ? ( {loading ? (
@@ -76,6 +81,10 @@ export function ImportStep3Screen() {
</> </>
)} )}
</button> </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> </div>
</div> </div>
+1 -1
View File
@@ -1,9 +1,9 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { appConfigDir } from '@tauri-apps/api/path'; import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { useState } from 'react'; import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form'; import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
+52 -40
View File
@@ -2,11 +2,10 @@ import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding'; import { useOnboarding } from '@stores/onboarding';
@@ -19,7 +18,7 @@ export function OnboardStep1Screen() {
const { publish, fetchUserData, prefetchEvents } = useNostr(); const { publish, fetchUserData, prefetchEvents } = useNostr();
const { db } = useStorage(); 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'); const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) { if (!res.ok) {
throw new Error('Error'); throw new Error('Error');
@@ -68,45 +67,51 @@ export function OnboardStep1Screen() {
}, []); }, []);
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="flex h-full w-full flex-col justify-center">
<div className="mb-8 text-center"> <div className="mx-auto mb-4 w-full max-w-md border-b border-white/10 pb-4">
<h1 className="text-xl font-semibold text-white"> <h1 className="mb-2 text-center text-2xl font-semibold text-white">
{loading ? 'Prefetching data...' : 'Enrich your network'} {loading ? 'Prefetching data...' : 'Enrich your network'}
</h1> </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>
<div className="flex flex-col gap-4"> <div className="scrollbar-hide flex w-full flex-nowrap items-center gap-4 overflow-x-auto px-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' ? (
{status === 'loading' ? ( <div className="flex h-full w-full items-center justify-center">
<div className="flex h-full w-full items-center justify-center"> <LoaderIcon className="h-4 w-4 animate-spin text-white" />
<LoaderIcon className="h-4 w-4 animate-spin text-white" /> </div>
</div> ) : (
) : ( data?.profiles.map((item: { pubkey: string; profile: { content: string } }) => (
data?.profiles.map( <button
(item: { pubkey: string; profile: { content: string } }) => ( key={item.pubkey}
<button type="button"
key={item.pubkey} onClick={() => toggleFollow(item.pubkey)}
type="button" 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"
onClick={() => toggleFollow(item.pubkey)} >
className="inline-flex transform items-center justify-between px-4 py-2 hover:bg-white/20" <User
> pubkey={item.pubkey}
<User pubkey={item.pubkey} fallback={item.profile?.content} /> variant="large"
{follows.includes(item.pubkey) && ( embedProfile={item.profile?.content}
<div> />
<CheckCircleIcon className="h-4 w-4 text-green-400" /> {follows.includes(item.pubkey) && (
</div> <div className="absolute right-2 top-2">
)} <CheckCircleIcon className="h-4 w-4 text-green-400" />
</button> </div>
) )}
) </button>
)} ))
</div> )}
</div>
<div className="mx-auto mt-4 w-full max-w-md">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<button <button
type="button" type="button"
onClick={submit} onClick={submit}
disabled={loading || follows.length === 0} 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 ? ( {loading ? (
<> <>
@@ -122,12 +127,19 @@ export function OnboardStep1Screen() {
</> </>
)} )}
</button> </button>
<Link {!loading ? (
to="/auth/onboarding/step-2" <Link
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" 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> 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> </div>
</div> </div>
+48 -19
View File
@@ -1,5 +1,6 @@
import { message } from '@tauri-apps/api/dialog';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
@@ -11,6 +12,7 @@ import { WidgetKinds } from '@stores/widgets';
const data = [ const data = [
{ hashtag: '#bitcoin' }, { hashtag: '#bitcoin' },
{ hashtag: '#nostr' }, { hashtag: '#nostr' },
{ hashtag: '#nostrdesign' },
{ hashtag: '#zap' }, { hashtag: '#zap' },
{ hashtag: '#LFG' }, { hashtag: '#LFG' },
{ hashtag: '#zapchain' }, { hashtag: '#zapchain' },
@@ -19,6 +21,10 @@ const data = [
{ hashtag: '#hodl' }, { hashtag: '#hodl' },
{ hashtag: '#stacksats' }, { hashtag: '#stacksats' },
{ hashtag: '#nokyc' }, { hashtag: '#nokyc' },
{ hashtag: '#meme' },
{ hashtag: '#memes' },
{ hashtag: '#memestr' },
{ hashtag: '#penisbutter' },
{ hashtag: '#anime' }, { hashtag: '#anime' },
{ hashtag: '#waifu' }, { hashtag: '#waifu' },
{ hashtag: '#manga' }, { hashtag: '#manga' },
@@ -28,8 +34,8 @@ const data = [
export function OnboardStep2Screen() { export function OnboardStep2Screen() {
const navigate = useNavigate(); const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const [setStep, clearStep] = useOnboarding((state) => [state.setStep, state.clearStep]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [tags, setTags] = useState(new Set<string>()); const [tags, setTags] = useState(new Set<string>());
@@ -47,17 +53,34 @@ export function OnboardStep2Screen() {
} }
}; };
const skip = async () => {
// update last login
await db.updateLastLogin();
// clear local storage
clearStep();
navigate('/auth/complete', { replace: true });
};
const submit = async () => { const submit = async () => {
try { try {
setLoading(true); setLoading(true);
for (const tag of tags) { for (const tag of tags) {
await db.createWidget(WidgetKinds.hashtag, tag, tag.replace('#', '')); await db.createWidget(WidgetKinds.global.hashtag, tag, tag.replace('#', ''));
} }
navigate('/auth/onboarding/step-3', { replace: true }); // update last login
} catch { await db.updateLastLogin();
console.log('error');
// clear local storage
clearStep();
navigate('/auth/complete', { replace: true });
} catch (e) {
setLoading(false);
await message(e, { title: 'Lume', type: 'error' });
} }
}; };
@@ -68,20 +91,23 @@ export function OnboardStep2Screen() {
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 border-b border-white/10 pb-4">
<h1 className="text-xl font-semibold text-white"> <h1 className="mb-2 text-center text-2xl font-semibold text-white">
Choose {tags.size}/3 your favorite tags Choose {tags.size}/3 your favorite hashtags
</h1> </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>
<div className="flex flex-col gap-4"> <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 }) => ( {data.map((item: { hashtag: string }) => (
<button <button
key={item.hashtag} key={item.hashtag}
type="button" type="button"
onClick={() => toggleTag(item.hashtag)} 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> <p className="text-white">{item.hashtag}</p>
{tags.has(item.hashtag) && ( {tags.has(item.hashtag) && (
@@ -97,7 +123,7 @@ export function OnboardStep2Screen() {
type="button" type="button"
onClick={submit} onClick={submit}
disabled={loading || tags.size === 0 || tags.size > 3} 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 ? ( {loading ? (
<> <>
@@ -113,12 +139,15 @@ export function OnboardStep2Screen() {
</> </>
)} )}
</button> </button>
<Link {!loading ? (
to="/auth/onboarding/step-3" <button
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" type="button"
> onClick={() => skip()}
Skip, you can add later 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"
</Link> >
Skip, you can add later
</button>
) : null}
</div> </div>
</div> </div>
</div> </div>
+12 -11
View File
@@ -2,12 +2,11 @@ import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { UserRelay } from '@app/auth/components/userRelay';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { FULL_RELAYS } from '@stores/constants'; import { FULL_RELAYS } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding'; import { useOnboarding } from '@stores/onboarding';
@@ -49,6 +48,8 @@ export function OnboardStep3Screen() {
} }
); );
const relaysAsArray = Array.from(data?.keys() || []);
const toggleRelay = (relay: string) => { const toggleRelay = (relay: string) => {
if (relays.has(relay)) { if (relays.has(relay)) {
setRelays((prev) => { setRelays((prev) => {
@@ -61,8 +62,9 @@ export function OnboardStep3Screen() {
}; };
const submit = async (skip?: boolean) => { const submit = async (skip?: boolean) => {
setLoading(true);
try { try {
setLoading(true);
if (!skip) { if (!skip) {
for (const relay of relays) { for (const relay of relays) {
await db.createRelay(relay); await db.createRelay(relay);
@@ -87,8 +89,6 @@ export function OnboardStep3Screen() {
} }
}; };
const relaysAsArray = Array.from(data?.keys() || []);
useEffect(() => { useEffect(() => {
// save current step, if user close app and reopen it // save current step, if user close app and reopen it
setStep('/auth/onboarding/step-3'); setStep('/auth/onboarding/step-3');
@@ -118,10 +118,11 @@ export function OnboardStep3Screen() {
<LoaderIcon className="h-4 w-4 animate-spin text-white" /> <LoaderIcon className="h-4 w-4 animate-spin text-white" />
</div> </div>
) : relaysAsArray.length === 0 ? ( ) : relaysAsArray.length === 0 ? (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center px-6">
<p className="text-center text-white/50"> <p className="text-center text-white/50">
Can&apos;t found any relays, you can skip this step and use default relays Lume couldn&apos;t find any relays from your follows.
instead <br />
You can skip this step and use default relays instead.
</p> </p>
</div> </div>
) : ( ) : (
@@ -134,7 +135,7 @@ export function OnboardStep3Screen() {
> >
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<p className="max-w-[15rem] truncate">{item.replace(/\/+$/, '')}</p> <p className="max-w-[15rem] truncate">{item.replace(/\/+$/, '')}</p>
<UserRelay pubkey={data.get(item)} /> <User pubkey={data.get(item)} variant="mention" />
</div> </div>
{relays.has(item) && ( {relays.has(item) && (
<div className="pt-1.5"> <div className="pt-1.5">
@@ -145,7 +146,7 @@ export function OnboardStep3Screen() {
)) ))
)} )}
{relays.size > 5 && ( {relays.size > 5 && (
<div className="sticky bottom-0 left-0 inline-flex w-full items-center justify-center bg-white/10 px-4 py-2 backdrop-blur-2xl backdrop-blur-xl"> <div className="sticky bottom-0 left-0 inline-flex w-full items-center justify-center bg-white/10 px-4 py-2 backdrop-blur-2xl">
<p className="text-sm text-orange-400"> <p className="text-sm text-orange-400">
Using too much relay can cause high resource usage Using too much relay can cause high resource usage
</p> </p>
@@ -178,7 +179,7 @@ export function OnboardStep3Screen() {
onClick={() => submit(true)} onClick={() => submit(true)}
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/10 focus:outline-none" className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/10 focus:outline-none"
> >
Skip, use default relays Skip, use Lume default relays
</button> </button>
</div> </div>
</div> </div>
+15 -9
View File
@@ -1,9 +1,9 @@
import { appConfigDir } from '@tauri-apps/api/path'; import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { getPublicKey, nip19 } from 'nostr-tools'; import { getPublicKey, nip19 } from 'nostr-tools';
import { useState } from 'react'; import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form'; import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
@@ -116,28 +116,28 @@ export function ResetScreen() {
</div> </div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3"> <form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="privkey" className="font-medium text-white/50"> <label htmlFor="privkey" className="font-medium text-white">
Private key Private key
</label> </label>
<div className="relative"> <div className="relative">
<input <input
{...register('privkey', { required: true })} {...register('privkey', { required: true })}
type="text" type="text"
placeholder="nsec..." placeholder="nsec1..."
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/10" 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> </div>
<div className="flex flex-col gap-1"> <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 Set a new password to protect your key
</label> </label>
<div className="relative"> <div className="relative">
<input <input
{...register('password', { required: true })} {...register('password', { required: true })}
type={passwordInput} type={passwordInput}
placeholder="min. 4 characters" 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/10" 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 <button
type="button" type="button"
@@ -155,7 +155,7 @@ export function ResetScreen() {
{errors.password && <p>{errors.password.message}</p>} {errors.password && <p>{errors.password.message}</p>}
</span> </span>
</div> </div>
<div className="flex items-center justify-center"> <div className="flex flex-col items-center justify-center">
<button <button
type="submit" type="submit"
disabled={!isDirty || !isValid} disabled={!isDirty || !isValid}
@@ -167,6 +167,12 @@ export function ResetScreen() {
'Continue →' 'Continue →'
)} )}
</button> </button>
<Link
to="/auth/unlock"
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>
</div> </div>
</form> </form>
</div> </div>
+15 -11
View File
@@ -1,14 +1,13 @@
import { appConfigDir } from '@tauri-apps/api/path'; import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { useState } from 'react'; import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form'; import { Resolver, useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router-dom'; 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 { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useStronghold } from '@stores/stronghold'; import { useStronghold } from '@stores/stronghold';
@@ -33,6 +32,7 @@ const resolver: Resolver<FormValues> = async (values) => {
export function UnlockScreen() { export function UnlockScreen() {
const navigate = useNavigate(); const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey); const setPrivkey = useStronghold((state) => state.setPrivkey);
const setWalletConnectURL = useStronghold((state) => state.setWalletConnectURL);
const [showPassword, setShowPassword] = useState<boolean>(false); const [showPassword, setShowPassword] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
@@ -55,8 +55,10 @@ export function UnlockScreen() {
if (!db.secureDB) db.secureDB = stronghold; if (!db.secureDB) db.secureDB = stronghold;
const privkey = await db.secureLoad(db.account.pubkey); 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 // redirect to home
navigate('/', { replace: true }); navigate('/', { replace: true });
} catch (e) { } catch (e) {
@@ -71,20 +73,22 @@ export function UnlockScreen() {
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-6 text-center"> <div className="mb-4 pb-4">
<h1 className="text-2xl font-semibold text-white">Enter password to unlock</h1> <h1 className="text-center text-2xl font-semibold text-white">
Enter password to unlock
</h1>
</div> </div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col"> <form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<div className="flex flex-col rounded-lg bg-white/5"> <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"> <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>
<div className="relative"> <div className="relative">
<input <input
{...register('password', { required: true, minLength: 4 })} {...register('password', { required: true, minLength: 4 })}
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
placeholder="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 <button
type="button" type="button"
@@ -106,12 +110,12 @@ export function UnlockScreen() {
<button <button
type="submit" type="submit"
disabled={!isDirty || !isValid} 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 ? ( {loading ? (
<> <>
<span className="w-5" /> <span className="w-5" />
<span>Decryting...</span> <span>Unlocking...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" /> <LoaderIcon className="h-5 w-5 animate-spin text-white" />
</> </>
) : ( ) : (
@@ -124,7 +128,7 @@ export function UnlockScreen() {
</button> </button>
<Link <Link
to="/auth/reset" 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 Reset password
</Link> </Link>
+10 -10
View File
@@ -1,4 +1,4 @@
import { LogicalSize, getCurrent } from '@tauri-apps/plugin-window'; import { LogicalSize, getCurrent } from '@tauri-apps/api/window';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@@ -28,19 +28,19 @@ export function WelcomeScreen() {
}, []); }, []);
return ( return (
<div className="flex h-screen w-full flex-col justify-between bg-white/10 backdrop-blur-xl"> <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-10 pt-16">
<div className="sflex flex-col gap-2 text-center"> <div className="flex flex-col gap-1.5 text-center">
<h1 className="text-3xl font-medium text-white">Welcome to Lume</h1> <h1 className="text-3xl font-semibold text-white">Welcome to Lume</h1>
<h3 className="mx-auto w-2/3 text-white/50"> <p className="mx-auto w-2/3 leading-tight text-white/50">
Let&apos;s get you up and connecting with all peoples around the world on Let&apos;s get you up and connecting with all peoples around the world on
Nostr Nostr
</h3> </p>
</div> </div>
<div className="inline-flex w-full flex-col items-center gap-3 px-4 pb-10"> <div className="inline-flex w-full flex-col items-center gap-3 px-4 pb-10">
<Link <Link
to="/auth/import" 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 className="w-5" />
<span>Login with private key</span> <span>Login with private key</span>
@@ -48,14 +48,14 @@ export function WelcomeScreen() {
</Link> </Link>
<Link <Link
to="/auth/create" 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/50 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 Create new key
</Link> </Link>
</div> </div>
</div> </div>
<div className="flex flex-1 items-end justify-center pb-10"> <div className="flex flex-1 items-end justify-center pb-6">
<img src="/lume.png" alt="lume" className="h-auto w-1/3" /> <img src="/lume.png" alt="lume" className="h-auto w-1/4" />
</div> </div>
</div> </div>
); );
+2 -2
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', 'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
isActive isActive
? 'border-fuchsia-500 bg-white/5 text-white' ? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/80' : 'border-transparent text-white/70'
) )
} }
> >
@@ -38,7 +38,7 @@ export function ChatsListItem({ pubkey }: { pubkey: string }) {
/> />
<div className="inline-flex w-full flex-1 items-center justify-between"> <div className="inline-flex w-full flex-1 items-center justify-between">
<h5 className="max-w-[10rem] truncate"> <h5 className="max-w-[10rem] truncate">
{user?.nip05 || user?.name || user?.display_name || displayNpub(pubkey, 16)} {user?.name || user?.display_name || displayNpub(pubkey, 16)}
</h5> </h5>
</div> </div>
</NavLink> </NavLink>
-4
View File
@@ -36,10 +36,6 @@ export function ChatsList() {
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" /> <div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="h-4 w-full animate-pulse rounded bg-white/10 backdrop-blur-xl" /> <div className="h-4 w-full animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div> </div>
<div className="inline-flex h-10 items-center gap-2.5 border-l-2 border-transparent pl-4">
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="h-4 w-full animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
</div> </div>
); );
} }
+1 -1
View File
@@ -22,7 +22,7 @@ export function ChatMessageItem({
return ( 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 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"> <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]"> <div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-white"> <p className="select-text whitespace-pre-line break-words text-base text-white">
{message.content} {message.content}
@@ -3,14 +3,14 @@ import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, MediaIcon } from '@shared/icons'; import { LoaderIcon, MediaIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader'; import { useNostr } from '@utils/hooks/useNostr';
export function MediaUploader({ export function MediaUploader({
setState, setState,
}: { }: {
setState: Dispatch<SetStateAction<string>>; setState: Dispatch<SetStateAction<string>>;
}) { }) {
const upload = useImageUploader(); const { upload } = useNostr();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadMedia = async () => { const uploadMedia = async () => {
+9 -10
View File
@@ -2,11 +2,10 @@ import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { CancelIcon, PlusIcon } from '@shared/icons'; import { CancelIcon, PlusIcon } from '@shared/icons';
import { User } from '@shared/user';
export function NewMessageModal() { export function NewMessageModal() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -35,7 +34,7 @@ export function NewMessageModal() {
</button> </button>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Portal className="relative z-10"> <Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" /> <Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <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 max-w-xl rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5"> <div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
@@ -44,7 +43,7 @@ export function NewMessageModal() {
<Dialog.Title className="text-lg font-semibold leading-none text-white"> <Dialog.Title className="text-lg font-semibold leading-none text-white">
New chat New chat
</Dialog.Title> </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" /> <CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close> </Dialog.Close>
</div> </div>
@@ -54,17 +53,17 @@ export function NewMessageModal() {
</div> </div>
</div> </div>
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-2 pt-2"> <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 <div
key={follow} 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={follow} /> <User pubkey={pubkey} variant="simple" />
<div> <div>
<button <button
type="button" type="button"
onClick={() => openChat(follow)} 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 Chat
</button> </button>
+12 -3
View File
@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Image } from '@shared/image'; import { Image } from '@shared/image';
import { NIP05 } from '@shared/nip05';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey'; import { displayNpub } from '@utils/shortenKey';
@@ -23,9 +24,17 @@ export function ChatSidebar({ pubkey }: { pubkey: string }) {
<h3 className="text-lg font-semibold leading-none"> <h3 className="text-lg font-semibold leading-none">
{user?.display_name || user?.name} {user?.display_name || user?.name}
</h3> </h3>
<h5 className="leading-none text-white/50"> {user?.nip05 ? (
{user?.nip05 || displayNpub(pubkey, 16)} <NIP05
</h5> pubkey={pubkey}
nip05={user?.nip05}
className="leading-none text-white/50"
/>
) : (
<span className="leading-none text-white/50">
{displayNpub(pubkey, 16)}
</span>
)}
</div> </div>
<div> <div>
<p className="leading-tight">{user?.bio || user?.about}</p> <p className="leading-tight">{user?.bio || user?.about}</p>
+6 -7
View File
@@ -2,9 +2,8 @@ import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { CancelIcon, StrangersIcon } from '@shared/icons'; import { CancelIcon, StrangersIcon } from '@shared/icons';
import { User } from '@shared/user';
import { compactNumber } from '@utils/number'; import { compactNumber } from '@utils/number';
@@ -35,7 +34,7 @@ export function UnknownsModal({ data }: { data: string[] }) {
</button> </button>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Portal className="relative z-10"> <Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" /> <Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <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 max-w-xl rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5"> <div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
@@ -44,7 +43,7 @@ export function UnknownsModal({ data }: { data: string[] }) {
<Dialog.Title className="text-lg font-semibold leading-none text-white"> <Dialog.Title className="text-lg font-semibold leading-none text-white">
{data.length} unknowns {data.length} unknowns
</Dialog.Title> </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" /> <CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close> </Dialog.Close>
</div> </div>
@@ -57,14 +56,14 @@ export function UnknownsModal({ data }: { data: string[] }) {
{data.map((pubkey) => ( {data.map((pubkey) => (
<div <div
key={pubkey} 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> <div>
<button <button
type="button" type="button"
onClick={() => openChat(pubkey)} 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 Chat
</button> </button>
+26 -13
View File
@@ -1,40 +1,53 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useLocation, useRouteError } from 'react-router-dom'; import { useLocation, useRouteError } from 'react-router-dom';
interface IRouteError { import { Frame } from '@shared/frame';
interface RouteError {
statusText: string; statusText: string;
message: string; message: string;
} }
interface IDebugInfo { interface DebugInfo {
os: null | string; os: null | string;
version: null | string; version: null | string;
appDir: null | string;
} }
export function ErrorScreen() { export function ErrorScreen() {
const error = useRouteError() as IRouteError; const error = useRouteError() as RouteError;
const location = useLocation(); const location = useLocation();
const [debugInfo, setDebugInfo] = useState<IDebugInfo>({ os: null, version: null }); const [debugInfo, setDebugInfo] = useState<DebugInfo>({
os: null,
version: null,
appDir: null,
});
useEffect(() => { useEffect(() => {
async function getInformation() { async function getInformation() {
const { platform, version } = await import('@tauri-apps/plugin-os'); const { platform, version } = await import('@tauri-apps/api/os');
const { getVersion } = await import('@tauri-apps/plugin-app'); const { getVersion } = await import('@tauri-apps/api/app');
const { appConfigDir } = await import('@tauri-apps/api/path');
const platformName = await platform(); const platformName = await platform();
const osVersion = await version(); const osVersion = await version();
const appVersion = await getVersion(); const appVersion = await getVersion();
const appDir = await appConfigDir();
setDebugInfo({ os: platformName + ' ' + osVersion, version: appVersion }); setDebugInfo({
os: platformName + ' ' + osVersion,
version: appVersion,
appDir: appDir,
});
} }
getInformation(); getInformation();
}, []); }, []);
return ( return (
<div className="flex h-full items-center justify-center bg-black/90 backdrop-blur-xl"> <Frame className="flex h-full items-center justify-center">
<div className="flex max-w-lg flex-col gap-4"> <div className="flex w-full flex-col gap-4 px-4 md:max-w-lg md:px-0">
<div className="flex flex-col"> <div className="flex flex-col">
<h1 className="mb-1 text-2xl font-semibold text-white"> <h1 className="mb-1 text-2xl font-semibold text-white">
Sorry, an unexpected error has occurred. Sorry, an unexpected error has occurred.
@@ -57,24 +70,24 @@ export function ErrorScreen() {
href="https://github.com/luminous-devs/lume/issues/new" href="https://github.com/luminous-devs/lume/issues/new"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium text-white backdrop-blur-xl hover:bg-white/20" className="inline-flex h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-white backdrop-blur-xl hover:bg-white/10"
> >
Click here to report the issue on GitHub Click here to report the issue on GitHub
</a> </a>
<button <button
type="button" type="button"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium text-white backdrop-blur-xl hover:bg-white/20" className="inline-flex h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-white backdrop-blur-xl hover:bg-white/10"
> >
Reload app Reload app
</button> </button>
<button <button
type="button" type="button"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium text-white backdrop-blur-xl hover:bg-white/20" className="inline-flex h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-white backdrop-blur-xl hover:bg-white/10"
> >
Reset app Reset app
</button> </button>
</div> </div>
</div> </div>
</div> </Frame>
); );
} }
+25 -17
View File
@@ -1,7 +1,7 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { writeText } from '@tauri-apps/api/clipboard';
import { nip19 } from 'nostr-tools'; 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 { useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
@@ -13,11 +13,11 @@ import {
NoteActions, NoteActions,
NoteReplyForm, NoteReplyForm,
NoteStats, NoteStats,
ThreadUser,
UnknownNote, UnknownNote,
} from '@shared/notes'; } from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list'; import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton'; import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent'; import { useEvent } from '@utils/hooks/useEvent';
@@ -27,13 +27,15 @@ export function ArticleNoteScreen() {
const { id } = useParams(); const { id } = useParams();
const { db } = useStorage(); 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 [isCopy, setIsCopy] = useState(false);
const share = async () => { const share = async () => {
await writeText( await writeText(
'https://nostr.com/' + 'https://njump.me/' +
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer) nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
); );
// update state // update state
@@ -98,21 +100,27 @@ export function ArticleNoteScreen() {
</div> </div>
</div> </div>
) : ( ) : (
<div className="h-min w-full px-3"> <>
<div className="rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl"> <div className="h-min w-full px-3">
<ThreadUser pubkey={data.pubkey} time={data.created_at} /> <div className="rounded-xl border-t border-white/10 bg-white/20 px-3 pt-3">
<div className="mt-2">{renderKind(data)}</div> <User pubkey={data.pubkey} time={data.created_at} variant="thread" />
<div> <div className="mt-2">{renderKind(data)}</div>
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} /> <div>
<NoteStats id={id} /> <NoteActions
id={data.id}
pubkey={data.pubkey}
extraButtons={false}
/>
<NoteStats id={data.id} />
</div>
</div> </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>
<div className="col-span-1" /> <div className="col-span-1" />
</div> </div>
+5 -5
View File
@@ -1,5 +1,5 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { writeText } from '@tauri-apps/api/clipboard';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/nip19'; import { EventPointer } from 'nostr-tools/lib/nip19';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
@@ -15,11 +15,11 @@ import {
NoteReplyForm, NoteReplyForm,
NoteStats, NoteStats,
TextNote, TextNote,
ThreadUser,
UnknownNote, UnknownNote,
} from '@shared/notes'; } from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list'; import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton'; import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent'; import { useEvent } from '@utils/hooks/useEvent';
@@ -35,7 +35,7 @@ export function TextNoteScreen() {
const share = async () => { const share = async () => {
await writeText( await writeText(
'https://nostr.com/' + 'https://njump.me/' +
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer) nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
); );
// update state // update state
@@ -51,7 +51,7 @@ export function TextNoteScreen() {
const renderKind = (event: NDKEvent) => { const renderKind = (event: NDKEvent) => {
switch (event.kind) { switch (event.kind) {
case NDKKind.Text: case NDKKind.Text:
return <TextNote event={event} />; return <TextNote content={event.content} />;
case NDKKind.Article: case NDKKind.Article:
return <ArticleNote event={event} />; return <ArticleNote event={event} />;
case 1063: case 1063:
@@ -106,7 +106,7 @@ export function TextNoteScreen() {
) : ( ) : (
<div className="h-min w-full px-3"> <div className="h-min w-full px-3">
<div className="rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl"> <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 className="mt-2">{renderKind(data)}</div>
<div> <div>
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} /> <NoteActions id={id} pubkey={data.pubkey} extraButtons={false} />
+11 -18
View File
@@ -1,32 +1,25 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useMemo } from 'react'; import { Link } from 'react-router-dom';
import { NotiContent } from '@app/notifications/components/content';
import { NotiUser } from '@app/notifications/components/user'; import { NotiUser } from '@app/notifications/components/user';
import { formatCreatedAt } from '@utils/createdAt'; import { formatCreatedAt } from '@utils/createdAt';
import { parser } from '@utils/parser';
export function NotiMention({ event }: { event: NDKEvent }) { export function NotiMention({ event }: { event: NDKEvent }) {
const createdAt = formatCreatedAt(event.created_at); const createdAt = formatCreatedAt(event.created_at);
const content = useMemo(() => parser(event), [event]); const rootId = event.tags.find((el) => el[0])?.[1];
return ( return (
<div className="h-min w-full px-3 py-1.5"> <Link to={`/notes/text/${rootId}`} className="h-min w-full px-3">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl"> <div className="group flex items-center justify-between rounded-xl px-3 py-3 hover:bg-white/10">
<div className="flex items-start justify-between"> <div className="flex items-center gap-2">
<div className="flex items-start gap-1"> <NotiUser pubkey={event.pubkey} />
<NotiUser pubkey={event.pubkey} /> <p className="leading-none text-white/50">has mention you · {createdAt}</p>
<p className="leading-none text-white/50">has reply you post · {createdAt}</p>
</div>
</div>
<div className="f- relative z-10 -mt-6 flex gap-3">
<div className="h-11 w-11 shrink-0" />
<div className="mb-2 mt-3 w-full cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl">
<NotiContent content={content} />
</div>
</div> </div>
<span className="hidden text-sm font-semibold text-fuchsia-500 group-hover:block">
View
</span>
</div> </div>
</div> </Link>
); );
} }
+13 -16
View File
@@ -1,30 +1,27 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
import { SimpleNote } from '@app/notifications/components/simpleNote';
import { NotiUser } from '@app/notifications/components/user'; import { NotiUser } from '@app/notifications/components/user';
import { formatCreatedAt } from '@utils/createdAt'; import { formatCreatedAt } from '@utils/createdAt';
export function NotiReaction({ event }: { event: NDKEvent }) { export function NotiReaction({ event }: { event: NDKEvent }) {
const root = event.tags.find((e) => e[0] === 'e')?.[1];
const createdAt = formatCreatedAt(event.created_at); const createdAt = formatCreatedAt(event.created_at);
const rootId = event.tags.find((el) => el[0])?.[1];
return ( return (
<div className="h-min w-full px-3 py-1.5"> <Link to={`/notes/text/${rootId}`} className="h-min w-full px-3">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl"> <div className="group flex items-center justify-between rounded-xl px-3 py-3 hover:bg-white/10">
<div className="flex items-start justify-between"> <div className="flex items-center gap-2">
<div className="flex items-start gap-1"> <NotiUser pubkey={event.pubkey} />
<NotiUser pubkey={event.pubkey} /> <p className="leading-none text-white/50">
<p className="leading-none text-white/50"> reacted {event.content} · {createdAt}
reacted {event.content} · {createdAt} </p>
</p>
</div>
</div>
<div className="relative z-10 -mt-6 flex gap-3">
<div className="h-11 w-11 shrink-0" />
<div className="flex-1">{root && <SimpleNote id={root} />}</div>
</div> </div>
<span className="hidden text-sm font-semibold text-fuchsia-500 group-hover:block">
View
</span>
</div> </div>
</div> </Link>
); );
} }
+15 -20
View File
@@ -1,6 +1,6 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
import { SimpleNote } from '@app/notifications/components/simpleNote';
import { NotiUser } from '@app/notifications/components/user'; import { NotiUser } from '@app/notifications/components/user';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
@@ -10,29 +10,24 @@ import { formatCreatedAt } from '@utils/createdAt';
export function NotiRepost({ event }: { event: NDKEvent }) { export function NotiRepost({ event }: { event: NDKEvent }) {
const { db } = useStorage(); const { db } = useStorage();
const root = event.tags.find((e) => e[0] === 'e')?.[1];
const createdAt = formatCreatedAt(event.created_at); const createdAt = formatCreatedAt(event.created_at);
const rootId = event.tags.find((el) => el[0])?.[1];
return ( return (
<div className="h-min w-full px-3 py-1.5"> <Link to={`/notes/text/${rootId}`} className="h-min w-full px-3">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl"> <div className="group flex items-center justify-between rounded-xl px-3 py-3 hover:bg-white/10">
<div className="flex items-start justify-between"> <div className="flex items-center gap-2">
<div className="flex items-start gap-1"> <NotiUser pubkey={event.pubkey} />
<NotiUser pubkey={event.pubkey} /> <p className="leading-none text-white/50">
<p className="leading-none text-white/50"> repost{' '}
repost{' '} {event.pubkey !== db.account.pubkey ? 'a post that mention you' : 'your post'}{' '}
{event.pubkey !== db.account.pubkey · {createdAt}
? 'a post that mention you' </p>
: 'your post'}{' '}
· {createdAt}
</p>
</div>
</div>
<div className="relative z-10 -mt-6 flex gap-3">
<div className="h-11 w-11 shrink-0" />
<div className="flex-1">{root && <SimpleNote id={root} />}</div>
</div> </div>
<span className="hidden text-sm font-semibold text-fuchsia-500 group-hover:block">
View
</span>
</div> </div>
</div> </Link>
); );
} }
@@ -18,7 +18,7 @@ export const SimpleNote = memo(function SimpleNote({ id }: { id: string }) {
const openThread = (event, thread: string) => { const openThread = (event, thread: string) => {
const selection = window.getSelection(); const selection = window.getSelection();
if (selection.toString().length === 0) { if (selection.toString().length === 0) {
setWidget(db, { kind: WidgetKinds.thread, title: 'Thread', content: thread }); setWidget(db, { kind: WidgetKinds.local.thread, title: 'Thread', content: thread });
} else { } else {
event.stopPropagation(); event.stopPropagation();
} }
@@ -48,7 +48,7 @@ export const SimpleNote = memo(function SimpleNote({ id }: { id: string }) {
tabIndex={0} tabIndex={0}
className="mb-2 mt-3 cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl" 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"> <div className="markdown">
<p> <p>
{data.content.length > 200 {data.content.length > 200
+4 -4
View File
@@ -18,14 +18,14 @@ export function NotiUser({ pubkey }: { pubkey: string }) {
} }
return ( return (
<div className="flex shrink-0 items-start justify-start gap-3"> <div className="flex shrink-0 items-center justify-start gap-2">
<Image <Image
src={user?.picture || user?.image} src={user?.picture || user?.image}
alt={pubkey} alt={pubkey}
className="h-11 w-11 shrink-0 rounded-lg object-cover" className="h-8 w-8 shrink-0 rounded-md object-cover"
/> />
<span className="max-w-[10rem] flex-1 truncate font-medium leading-none text-white"> <span className="max-w-[10rem] truncate font-medium leading-none text-white">
{user?.nip05 || user?.name || user?.display_name || displayNpub(pubkey, 16)} {user?.name || user?.display_name || displayNpub(pubkey, 16)}
</span> </span>
</div> </div>
); );
+39 -29
View File
@@ -1,6 +1,5 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useCallback, useEffect } from 'react';
import { useCallback } from 'react';
import { NotiMention } from '@app/notifications/components/mention'; import { NotiMention } from '@app/notifications/components/mention';
import { NotiReaction } from '@app/notifications/components/reaction'; import { NotiReaction } from '@app/notifications/components/reaction';
@@ -11,18 +10,19 @@ import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { TitleBar } from '@shared/titleBar'; import { TitleBar } from '@shared/titleBar';
import { useActivities } from '@stores/activities';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
export function NotificationScreen() { export function NotificationScreen() {
const { db } = useStorage(); const { db } = useStorage();
const { fetchActivities } = useNostr(); const { fetchActivities } = useNostr();
const { status, data } = useQuery(
['notifications', db.account.pubkey], const [activities, setActivities, clearTotalNewActivities] = useActivities((state) => [
async () => { state.activities,
return await fetchActivities(); state.setActivities,
}, state.clearTotalNewActivities,
{ refetchOnWindowFocus: false } ]);
);
const renderItem = useCallback( const renderItem = useCallback(
(event: NDKEvent) => { (event: NDKEvent) => {
@@ -37,34 +37,44 @@ export function NotificationScreen() {
return null; return null;
} }
}, },
[data] [activities]
); );
useEffect(() => {
async function getActivities() {
const events = await fetchActivities();
setActivities(events, db.account.last_login_at);
}
getActivities();
// clear total new activities
clearTotalNewActivities();
}, []);
return ( return (
<div className="scrollbar-hide h-full w-full overflow-y-auto bg-white/10 backdrop-blur-xl"> <div className="scrollbar-hide h-full w-full overflow-y-auto bg-white/10 backdrop-blur-xl">
<div className="grid h-full grid-cols-3"> <div className="grid h-full grid-cols-3">
<div className="col-span-2 flex flex-col border-r border-white/5"> <div className="col-span-2 flex flex-col border-r border-white/5">
<TitleBar title="Activities in the last 24 hours" /> <TitleBar title="Activities in the last 24 hours" />
<div className="flex h-full flex-col gap-1.5"> <div className="flex h-full flex-col">
<div className="flex h-full flex-col"> {!activities ? (
{status === 'loading' ? ( <div className="flex h-full w-full items-center justify-center">
<div className="flex h-full w-full items-center justify-center"> <div className="flex flex-col items-center gap-1.5">
<div className="flex flex-col items-center gap-1.5"> <LoaderIcon className="h-5 w-5 animate-spin text-white" />
<LoaderIcon className="h-5 w-5 animate-spin text-white" /> <p className="text-sm font-medium text-white/50">Loading</p>
<p className="text-sm font-medium text-white/50">Loading</p>
</div>
</div> </div>
) : data?.length < 1 ? ( </div>
<div className="flex h-full w-full flex-col items-center justify-center"> ) : activities.length <= 1 ? (
<p className="mb-1 text-4xl">🎉</p> <div className="flex h-full w-full flex-col items-center justify-center">
<p className="font-medium text-white/50"> <p className="mb-1 text-4xl">🎉</p>
Yo!, no new activities around you in the last 24 hours <p className="font-medium text-white/50">
</p> Yo!, no new activities around you in the last 24 hours
</div> </p>
) : ( </div>
data.map((event) => renderItem(event)) ) : (
)} activities.map((event) => renderItem(event))
</div> )}
</div> </div>
</div> </div>
</div> </div>
+152
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>
);
}
+168
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
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>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { getVersion } from '@tauri-apps/plugin-app'; import { getVersion } from '@tauri-apps/api/app';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export function VersionSetting() { export function VersionSetting() {
+80 -8
View File
@@ -2,9 +2,18 @@ import { useCallback } from 'react';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import {
ArticleIcon,
FileIcon,
FollowsIcon,
GroupFeedsIcon,
HashtagIcon,
ThreadsIcon,
TrendingIcon,
} from '@shared/icons';
import { TitleBar } from '@shared/titleBar'; import { TitleBar } from '@shared/titleBar';
import { DefaultWidgets, useWidgets } from '@stores/widgets'; import { DefaultWidgets, WidgetKinds, useWidgets } from '@stores/widgets';
import { Widget, WidgetGroup, WidgetGroupItem } from '@utils/types'; import { Widget, WidgetGroup, WidgetGroupItem } from '@utils/types';
@@ -20,16 +29,63 @@ export function WidgetList({ params }: { params: Widget }) {
removeWidget(db, params.id); removeWidget(db, params.id);
}; };
const renderIcon = useCallback(
(kind: number) => {
switch (kind) {
case WidgetKinds.tmp.xfeed:
return <GroupFeedsIcon className="h-5 w-5 text-white" />;
case WidgetKinds.local.follows:
return <FollowsIcon className="h-5 w-5 text-white" />;
case WidgetKinds.local.files:
case WidgetKinds.global.files:
return <FileIcon className="h-5 w-5 text-white" />;
case WidgetKinds.local.articles:
case WidgetKinds.global.articles:
return <ArticleIcon className="h-5 w-5 text-white" />;
case WidgetKinds.tmp.xhashtag:
return <HashtagIcon className="h-5 w-4 text-white" />;
case WidgetKinds.nostrBand.trendingAccounts:
case WidgetKinds.nostrBand.trendingNotes:
return <TrendingIcon className="h-5 w-4 text-white" />;
case WidgetKinds.other.learnNostr:
return <ThreadsIcon className="h-5 w-4 text-white" />;
default:
return null;
}
},
[DefaultWidgets]
);
const renderItem = useCallback( const renderItem = useCallback(
(row: WidgetGroup) => { (row: WidgetGroup) => {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h3 className="font-medium text-white/50">{row.title}</h3> <h3 className="font-medium text-white/50">{row.title}</h3>
<div className="grid grid-cols-3 gap-6"> <div className="flex flex-col divide-y divide-white/5 overflow-hidden rounded-xl bg-white/10">
{row.data.map((item, index) => ( {row.data.map((item, index) => (
<button onClick={() => openWidget(item)} key={index}> <button
<div className="inline-flex aspect-square h-full w-full transform-gpu flex-col items-center justify-center gap-2.5 rounded-2xl bg-white/5 hover:bg-white/10"> onClick={() => openWidget(item)}
<h5 className="text-sm font-medium">{item.title}</h5> key={index}
className="flex items-center gap-2.5 px-4 hover:bg-white/10"
>
{item.icon ? (
<div className="h-10 w-10 shrink-0 rounded-md">
<img
src={item.icon}
alt={item.title}
className="h-10 w-10 object-cover"
/>
</div>
) : (
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-white/10">
{renderIcon(item.kind)}
</div>
)}
<div className="inline-flex h-16 w-full flex-col items-start justify-center gap-1">
<h5 className="line-clamp-1 font-medium leading-none">{item.title}</h5>
<p className="line-clamp-1 text-xs leading-none text-white/50">
{item.description}
</p>
</div> </div>
</button> </button>
))} ))}
@@ -41,10 +97,26 @@ export function WidgetList({ params }: { params: Widget }) {
); );
return ( return (
<div className="relative h-full shrink-0 grow-0 basis-[400px] overflow-hidden"> <div className="relative h-full shrink-0 grow-0 basis-[400px] bg-white/10">
<TitleBar id={params.id} title="Add widget" /> <TitleBar id={params.id} title="Add widget" />
<div className="flex flex-col gap-8 px-3"> <div className="scrollbar-hide h-full overflow-y-auto pb-20">
{DefaultWidgets.map((row: WidgetGroup) => renderItem(row))} <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>
</div> </div>
); );
+7 -1
View File
@@ -10,9 +10,11 @@ import {
GlobalArticlesWidget, GlobalArticlesWidget,
GlobalFilesWidget, GlobalFilesWidget,
GlobalHashtagWidget, GlobalHashtagWidget,
LearnNostrWidget,
LocalArticlesWidget, LocalArticlesWidget,
LocalFeedsWidget, LocalFeedsWidget,
LocalFilesWidget, LocalFilesWidget,
LocalFollowsWidget,
LocalNetworkWidget, LocalNetworkWidget,
LocalThreadWidget, LocalThreadWidget,
LocalUserWidget, LocalUserWidget,
@@ -40,6 +42,8 @@ export function SpaceScreen() {
switch (widget.kind) { switch (widget.kind) {
case WidgetKinds.local.network: case WidgetKinds.local.network:
return <LocalNetworkWidget key={widget.id} />; return <LocalNetworkWidget key={widget.id} />;
case WidgetKinds.local.follows:
return <LocalFollowsWidget key={widget.id} params={widget} />;
case WidgetKinds.local.feeds: case WidgetKinds.local.feeds:
return <LocalFeedsWidget key={widget.id} params={widget} />; return <LocalFeedsWidget key={widget.id} params={widget} />;
case WidgetKinds.local.files: case WidgetKinds.local.files:
@@ -66,8 +70,10 @@ export function SpaceScreen() {
return <XfeedsWidget key={widget.id} params={widget} />; return <XfeedsWidget key={widget.id} params={widget} />;
case WidgetKinds.tmp.list: case WidgetKinds.tmp.list:
return <WidgetList key={widget.id} params={widget} />; return <WidgetList key={widget.id} params={widget} />;
case WidgetKinds.other.learnNostr:
return <LearnNostrWidget key={widget.id} params={widget} />;
default: default:
break; return null;
} }
}, },
[widgets] [widgets]
+29 -26
View File
@@ -1,3 +1,4 @@
import { message } from '@tauri-apps/api/dialog';
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -10,48 +11,53 @@ import { useNostr } from '@utils/hooks/useNostr';
export function SplashScreen() { export function SplashScreen() {
const { db } = useStorage(); const { db } = useStorage();
const { ndk, relayUrls } = useNDK(); const { ndk } = useNDK();
const { fetchUserData, prefetchEvents } = useNostr(); const { fetchUserData, prefetchEvents } = useNostr();
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const skip = async () => { const skip = async () => {
await invoke('close_splashscreen'); await invoke('close_splashscreen');
}; };
const prefetch = async () => { const prefetch = async () => {
const onboarding = localStorage.getItem('onboarding');
const step = JSON.parse(onboarding).state.step || null;
if (step) await invoke('close_splashscreen');
try { try {
const user = await fetchUserData(); const [user, events] = await Promise.all([fetchUserData(), prefetchEvents()]);
const data = await prefetchEvents();
if (user.status === 'ok' && data.status === 'ok') { if (user.status === 'ok' && events.status === 'ok') {
// update last login = current time
await db.updateLastLogin(); await db.updateLastLogin();
// close splash screen and open main app screen
await invoke('close_splashscreen'); await invoke('close_splashscreen');
} else {
setIsLoading(false);
setErrorMessage(user.message);
console.log('fetch failed, error: ', user.message);
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
setErrorMessage(e); await message(e, {
console.log('prefetch failed, error: ', e); title: 'An unexpected error has occurred',
type: 'error',
});
} }
}; };
useEffect(() => { useEffect(() => {
if (!db.account) { async function initial() {
invoke('close_splashscreen'); if (!db.account) {
await invoke('close_splashscreen');
} else {
const onboarding = localStorage.getItem('onboarding');
const step = JSON.parse(onboarding).state.step || null;
if (step) {
await invoke('close_splashscreen');
} else {
console.log('prefetching...');
prefetch();
}
}
} }
if (ndk && db.account) { if (ndk) {
console.log('prefetching...'); initial();
prefetch();
} }
}, [ndk, db.account]); }, [ndk, db.account]);
@@ -64,9 +70,7 @@ export function SplashScreen() {
{isLoading ? ( {isLoading ? (
<div className="flex flex-col gap-1 text-center"> <div className="flex flex-col gap-1 text-center">
<h3 className="text-lg font-semibold leading-none text-white"> <h3 className="text-lg font-semibold leading-none text-white">
{!ndk {!ndk ? 'Connecting to relay...' : 'Fetching events from the last login.'}
? 'Connecting to relay...'
: `Connected to ${relayUrls.length} relays`}
</h3> </h3>
<p className="text-sm text-white/50"> <p className="text-sm text-white/50">
This may take a few seconds, please don&apos;t close app. This may take a few seconds, please don&apos;t close app.
@@ -75,15 +79,14 @@ export function SplashScreen() {
) : ( ) : (
<div className="mt-2 flex flex-col gap-1 text-center"> <div className="mt-2 flex flex-col gap-1 text-center">
<h3 className="text-lg font-semibold leading-none text-white"> <h3 className="text-lg font-semibold leading-none text-white">
Something wrong! An unexpected error has occurred
</h3> </h3>
<p className="text-sm text-white/50">{errorMessage}</p>
<button <button
type="button" type="button"
onClick={skip} onClick={skip}
className="mx-auto mt-4 inline-flex h-10 w-max items-center justify-center rounded-md bg-white/10 px-8 text-sm font-medium leading-none text-white backdrop-blur-xl hover:bg-white/20" className="mx-auto mt-4 inline-flex h-10 w-max items-center justify-center rounded-md bg-white/10 px-8 text-sm font-medium leading-none text-white backdrop-blur-xl hover:bg-white/20"
> >
Skip Skip this step
</button> </button>
</div> </div>
)} )}
+22 -8
View File
@@ -1,6 +1,7 @@
import { NDKEvent, NDKUserProfile } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKUserProfile } from '@nostr-dev-kit/ndk';
import * as Dialog from '@radix-ui/react-dialog'; import * as Dialog from '@radix-ui/react-dialog';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/api/http';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -13,6 +14,12 @@ import { Image } from '@shared/image';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
interface NIP05 {
names: {
[key: string]: string;
};
}
export function EditProfileModal() { export function EditProfileModal() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -47,19 +54,26 @@ export function EditProfileModal() {
}); });
const verifyNIP05 = async (nip05: string) => { const verifyNIP05 = async (nip05: string) => {
const url = nip05.split('@'); const localPath = nip05.split('@')[0];
const username = url[0]; const service = nip05.split('@')[1];
const service = url[1]; const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
const verifyURL = `https://${service}/.well-known/nostr.json?name=${username}`;
const res = await fetch(verifyURL, { const res = await fetch(verifyURL, {
method: 'GET',
timeout: 10,
headers: { headers: {
'Content-Type': 'application/json; charset=utf-8', 'Content-Type': 'application/json; charset=utf-8',
}, },
}); });
console.log(res); if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
return true;
const data = res.data as NIP05;
if (data.names) {
if (data.names[localPath] !== db.account.pubkey) return false;
return true;
}
return false;
}; };
const onSubmit = async (data: NDKUserProfile) => { const onSubmit = async (data: NDKUserProfile) => {
@@ -131,7 +145,7 @@ export function EditProfileModal() {
</button> </button>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Portal className="relative z-10"> <Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" /> <Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <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 max-w-xl rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5"> <div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
@@ -275,7 +289,7 @@ export function EditProfileModal() {
<button <button
type="submit" type="submit"
disabled={!isValid} 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 ? ( {loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" /> <LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
+16 -7
View File
@@ -7,10 +7,11 @@ import { UserStats } from '@app/users/components/stats';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { Image } from '@shared/image'; import { Image } from '@shared/image';
import { NIP05 } from '@shared/nip05';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey'; import { displayNpub } from '@utils/shortenKey';
export function UserProfile({ pubkey }: { pubkey: string }) { export function UserProfile({ pubkey }: { pubkey: string }) {
const { db } = useStorage(); const { db } = useStorage();
@@ -57,7 +58,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
) : ( ) : (
<div className="h-full w-full bg-black/50" /> <div className="h-full w-full bg-black" />
)} )}
</div> </div>
<div className="-mt-7 flex w-full flex-col items-center px-5"> <div className="-mt-7 flex w-full flex-col items-center px-5">
@@ -68,13 +69,21 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
/> />
<div className="mt-2 flex flex-1 flex-col gap-6"> <div className="mt-2 flex flex-1 flex-col gap-6">
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<div className="inline-flex flex-col gap-1.5"> <div className="inline-flex flex-col items-center gap-1.5">
<h5 className="text-center text-xl font-semibold leading-none"> <h5 className="text-center text-xl font-semibold leading-none">
{user.display_name || user.displayName || user.name || 'No name'} {user.display_name || user.displayName || user.name || 'No name'}
</h5> </h5>
<span className="max-w-[15rem] truncate text-center leading-none text-white/50"> {user.nip05 ? (
{user.nip05 || user.username || shortenKey(pubkey)} <NIP05
</span> pubkey={pubkey}
nip05={user?.nip05}
className="max-w-[15rem] truncate text-sm leading-none text-white/50"
/>
) : (
<span className="max-w-[15rem] truncate text-sm leading-none text-white/50">
{displayNpub(pubkey, 16)}
</span>
)}
</div> </div>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{user.about || user.bio ? ( {user.about || user.bio ? (
@@ -82,7 +91,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
{user.about || user.bio} {user.about || user.bio}
</p> </p>
) : ( ) : (
<></> <div />
)} )}
<UserStats pubkey={pubkey} /> <UserStats pubkey={pubkey} />
</div> </div>
+1 -1
View File
@@ -56,7 +56,7 @@ export function UserScreen() {
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
> >
<NoteWrapper event={event}> <NoteWrapper event={event}>
<TextNote event={event} /> <TextNote content={event.content} />
</NoteWrapper> </NoteWrapper>
</div> </div>
); );
+30 -23
View File
@@ -2,20 +2,21 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
html { .scrollbar-hide::-webkit-scrollbar {
font-size: 14px; display: none;
} }
a { .scrollbar-hide {
@apply cursor-default no-underline !important; -ms-overflow-style: none;
} scrollbar-width: none;
button {
@apply cursor-default focus:outline-none;
} }
.markdown { .markdown {
@apply prose prose-white max-w-none select-text hyphens-auto text-white prose-p:mb-2 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal 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 { .ProseMirror p.is-empty::before {
@@ -30,25 +31,31 @@ button {
@apply outline-fuchsia-500; @apply outline-fuchsia-500;
} }
html {
font-size: 14px;
}
a {
@apply cursor-default no-underline !important;
}
button {
@apply cursor-default focus:outline-none;
}
iframe { iframe {
height: auto !important; height: auto !important;
} }
/* For Webkit-based browsers (Chrome, Safari and Opera) */ span[data-slate-placeholder] {
.scrollbar-hide::-webkit-scrollbar { margin-top: 0;
}
input::-ms-reveal,
input::-ms-clear {
display: none; display: none;
} }
/* For IE, Edge and Firefox */ ::-webkit-input-placeholder {
.scrollbar-hide { line-height: normal;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.border {
background-clip: padding-box;
}
span[data-slate-placeholder] {
@apply top-0;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import { NDKCacheAdapter } from '@nostr-dev-kit/ndk'; import { NDKCacheAdapter } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk';
import { Store } from '@tauri-apps/plugin-store'; import { Store } from 'tauri-plugin-store-api';
export default class TauriAdapter implements NDKCacheAdapter { export default class TauriAdapter implements NDKCacheAdapter {
public store: Store; public store: Store;
+34 -50
View File
@@ -1,85 +1,69 @@
// inspire by: https://github.com/nostr-dev-kit/ndk-react/ // inspire by: https://github.com/nostr-dev-kit/ndk-react/
import NDK from '@nostr-dev-kit/ndk'; import NDK from '@nostr-dev-kit/ndk';
import { message } from '@tauri-apps/api/dialog';
import { fetch } from '@tauri-apps/api/http';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import TauriAdapter from '@libs/ndk/cache'; import TauriAdapter from '@libs/ndk/cache';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { FULL_RELAYS } from '@stores/constants';
export const NDKInstance = () => { export const NDKInstance = () => {
const { db } = useStorage();
const [ndk, setNDK] = useState<NDK | undefined>(undefined); const [ndk, setNDK] = useState<NDK | undefined>(undefined);
const [relayUrls, setRelayUrls] = useState<string[]>([]); const [relayUrls, setRelayUrls] = useState<string[]>([]);
const { db } = useStorage();
const cacheAdapter = useMemo(() => new TauriAdapter(), [ndk]); const cacheAdapter = useMemo(() => new TauriAdapter(), [ndk]);
// TODO: fully support NIP-11 // TODO: fully support NIP-11
async function verifyRelays(relays: string[]) { async function getExplicitRelays() {
try { try {
const urls: string[] = relays.map((relay) => { // get relays
if (relay.startsWith('ws')) { const relays = await db.getExplicitRelayUrls();
return relay.replace('ws', 'http'); const requests = relays.map((relay) => {
} const url = new URL(relay);
if (relay.startsWith('wss')) { return fetch(`https://${url.hostname + url.pathname}`, {
return relay.replace('wss', 'https'); method: 'GET',
} timeout: 10,
headers: {
Accept: 'application/nostr+json',
},
});
}); });
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort('timeout'), 5000);
const requests = urls.map((url) =>
fetch(url, {
headers: { Accept: 'application/nostr+json' },
signal: controller.signal,
})
);
const responses = await Promise.all(requests); const responses = await Promise.all(requests);
const errors = responses.filter((response) => !response.ok); const successes = responses.filter((res) => res.ok);
if (errors.length > 0) { const verifiedRelays: string[] = successes.map((res) => {
throw errors.map((response) => Error(response.statusText)); // TODO: support payment
} // @ts-expect-error, not have type yet
if (!res.data.limitation?.payment_required) {
const verifiedRelays: string[] = responses.map((res) => { const url = new URL(res.url);
if (res.url.startsWith('http')) { if (url.protocol === 'http:') return `ws://${url.hostname + url.pathname}`;
return res.url.replace('htto', 'ws'); if (url.protocol === 'https:') return `wss://${url.hostname + url.pathname}`;
}
if (res.url.startsWith('https')) {
return res.url.replace('https', 'wss');
} }
}); });
// clear timeout // return all validated relays
clearTimeout(timeoutId);
// return all validate relays
return verifiedRelays; return verifiedRelays;
} catch (e) { } catch (e) {
console.error('ndk instance error: ', e); console.error(e);
} }
} }
async function initNDK() { async function initNDK() {
let explicitRelayUrls: string[]; const explicitRelayUrls = await getExplicitRelays();
const explicitRelayUrlsFromDB = await db.getExplicitRelayUrls(); const instance = new NDK({
console.log('relays in db: ', explicitRelayUrlsFromDB); explicitRelayUrls,
cacheAdapter,
if (explicitRelayUrlsFromDB) { });
explicitRelayUrls = await verifyRelays(explicitRelayUrlsFromDB);
} else {
explicitRelayUrls = await verifyRelays(FULL_RELAYS);
}
console.log('ndk cache adapter: ', cacheAdapter);
const instance = new NDK({ explicitRelayUrls, cacheAdapter });
try { try {
await instance.connect(10000); await instance.connect(10000);
} catch (error) { } catch (error) {
throw new Error('NDK instance init failed: ', error); await message(`NDK instance init failed: ${error}`, {
title: 'Lume',
type: 'error',
});
} }
setNDK(instance); setNDK(instance);
+2 -2
View File
@@ -5,12 +5,12 @@ import { PropsWithChildren, createContext, useContext } from 'react';
import { NDKInstance } from '@libs/ndk/instance'; import { NDKInstance } from '@libs/ndk/instance';
interface NDKContext { interface NDKContext {
ndk: NDK; ndk: undefined | NDK;
relayUrls: string[]; relayUrls: string[];
} }
const NDKContext = createContext<NDKContext>({ const NDKContext = createContext<NDKContext>({
ndk: new NDK({}), ndk: undefined,
relayUrls: [], relayUrls: [],
}); });
+55 -24
View File
@@ -1,44 +1,55 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { BaseDirectory, removeFile } from '@tauri-apps/plugin-fs'; import { BaseDirectory, removeFile } from '@tauri-apps/api/fs';
import Database from '@tauri-apps/plugin-sql'; import { Platform } from '@tauri-apps/api/os';
import { Stronghold } from '@tauri-apps/plugin-stronghold'; 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'; import { Account, DBEvent, Relays, Widget } from '@utils/types';
export class LumeStorage { export class LumeStorage {
public db: Database; public db: Database;
public platform: Platform;
public secureDB: Stronghold; public secureDB: Stronghold;
public account: Account | null = null; public account: Account | null = null;
constructor(sqlite: Database, stronghold?: Stronghold) { constructor(sqlite: Database, platform: Platform, stronghold?: Stronghold) {
this.db = sqlite; this.db = sqlite;
this.platform = platform ?? undefined;
this.secureDB = stronghold ?? undefined; this.secureDB = stronghold ?? undefined;
this.account = null; this.account = null;
} }
private async getSecureClient() { private async getSecureClient(key?: string) {
try { try {
return await this.secureDB.loadClient('lume'); return await this.secureDB.loadClient(key ?? 'lume');
} catch { } 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"); 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(); const store = client.getStore();
console.log('insert key: ', key);
await store.insert(key, Array.from(new TextEncoder().encode(value))); 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"); 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(); const store = client.getStore();
console.log('get key: ', key);
const value = await store.get(key); const value = await store.get(key);
if (!value) return null;
const decoded = new TextDecoder().decode(new Uint8Array(value)); const decoded = new TextDecoder().decode(new Uint8Array(value));
return decoded; return decoded;
} }
@@ -134,18 +145,29 @@ export class LumeStorage {
return await this.db.execute('DELETE FROM widgets WHERE id = $1;', [id]); return await this.db.execute('DELETE FROM widgets WHERE id = $1;', [id]);
} }
public async createEvent( public async createEvent(event: NDKEvent) {
id: string, let root: string;
event: string, let reply: string;
author: string,
kind: number, if (event.tags?.[0]?.[0] === 'e' && !event.tags?.[0]?.[3]) {
root_id: string, root = event.tags[0][1];
reply_id: string, } else {
created_at: number root = event.tags.find((el) => el[3] === 'root')?.[1];
) { reply = event.tags.find((el) => el[3] === 'reply')?.[1];
}
return await this.db.execute( 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);', '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,
]
); );
} }
@@ -261,13 +283,13 @@ export class LumeStorage {
} }
public async getExplicitRelayUrls() { public async getExplicitRelayUrls() {
if (!this.account) return null; if (!this.account) return FULL_RELAYS;
const result: Relays[] = await this.db.select( const result: Relays[] = await this.db.select(
`SELECT * FROM relays WHERE account_id = "${this.account.id}" ORDER BY id DESC LIMIT 50;` `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); return result.map((el) => el.relay);
} }
@@ -288,6 +310,15 @@ export class LumeStorage {
); );
} }
public async accountLogout() {
await this.db.execute("UPDATE accounts SET is_active = '0' WHERE id = $1;", [
this.account.id,
]);
this.account = null;
return true;
}
public async close() { public async close() {
return this.db.close(); return this.db.close();
} }
+19 -5
View File
@@ -1,5 +1,8 @@
import Database from '@tauri-apps/plugin-sql'; import { message } from '@tauri-apps/api/dialog';
import { platform } from '@tauri-apps/api/os';
import { appConfigDir } from '@tauri-apps/api/path';
import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react'; import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react';
import Database from 'tauri-plugin-sql-api';
import { LumeStorage } from '@libs/storage/instance'; import { LumeStorage } from '@libs/storage/instance';
@@ -15,11 +18,22 @@ const StorageProvider = ({ children }: PropsWithChildren<object>) => {
const [db, setDB] = useState<LumeStorage>(undefined); const [db, setDB] = useState<LumeStorage>(undefined);
async function initLumeStorage() { async function initLumeStorage() {
const sqlite = await Database.load('sqlite:lume.db'); try {
const lumeStorage = new LumeStorage(sqlite); const dir = await appConfigDir();
const sqlite = await Database.load('sqlite:lume.db');
const platformName = await platform();
const lumeStorage = new LumeStorage(sqlite, platformName);
if (!lumeStorage.account) await lumeStorage.getActiveAccount(); console.log('App config dir: ', dir);
setDB(lumeStorage);
if (!lumeStorage.account) await lumeStorage.getActiveAccount();
setDB(lumeStorage);
} catch (e) {
await message(`Cannot initialize database: ${e}`, {
title: 'Lume',
type: 'error',
});
}
} }
useEffect(() => { useEffect(() => {
+10 -5
View File
@@ -6,6 +6,8 @@ import { useStorage } from '@libs/storage/provider';
import { AccountMoreActions } from '@shared/accounts/more'; import { AccountMoreActions } from '@shared/accounts/more';
import { Image } from '@shared/image'; import { Image } from '@shared/image';
import { useActivities } from '@stores/activities';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
import { sendNativeNotification } from '@utils/notification'; import { sendNativeNotification } from '@utils/notification';
@@ -16,6 +18,8 @@ export function ActiveAccount() {
const { status, user } = useProfile(db.account.pubkey); const { status, user } = useProfile(db.account.pubkey);
const { sub } = useNostr(); const { sub } = useNostr();
const addActivity = useActivities((state) => state.addActivity);
useEffect(() => { useEffect(() => {
const filter: NDKFilter = { const filter: NDKFilter = {
'#p': [db.account.pubkey], '#p': [db.account.pubkey],
@@ -30,11 +34,13 @@ export function ActiveAccount() {
}; };
sub(filter, async (event) => { sub(filter, async (event) => {
addActivity(event);
switch (event.kind) { switch (event.kind) {
case NDKKind.Text: case NDKKind.Text:
return await sendNativeNotification('Mention'); return await sendNativeNotification('Mention');
case NDKKind.Contacts: case NDKKind.Contacts:
return await sendNativeNotification("You've new follower"); return await sendNativeNotification("You've a new follower");
case NDKKind.Repost: case NDKKind.Repost:
return await sendNativeNotification('Repost'); return await sendNativeNotification('Repost');
case NDKKind.Reaction: case NDKKind.Reaction:
@@ -42,7 +48,6 @@ export function ActiveAccount() {
case NDKKind.Zap: case NDKKind.Zap:
return await sendNativeNotification('Zap'); return await sendNativeNotification('Zap');
default: default:
console.log('[notify] new event: ', event);
break; break;
} }
}); });
@@ -50,8 +55,8 @@ export function ActiveAccount() {
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="inline-flex h-10 items-center gap-2.5 rounded-md px-2"> <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-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" /> <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 className="h-2.5 w-2/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div> </div>
); );
@@ -67,7 +72,7 @@ export function ActiveAccount() {
/> />
<div className="flex w-full flex-1 flex-col items-start gap-1.5"> <div className="flex w-full flex-1 flex-col items-start gap-1.5">
<p className="max-w-[10rem] truncate font-bold leading-none text-white"> <p className="max-w-[10rem] truncate font-bold leading-none text-white">
{user?.name || user?.display_name || user?.nip05} {user?.name || user?.display_name}
</p> </p>
<span className="max-w-[8rem] truncate text-sm leading-none text-white/50"> <span className="max-w-[8rem] truncate text-sm leading-none text-white/50">
{displayNpub(db.account.pubkey, 16)} {displayNpub(db.account.pubkey, 16)}
+3 -4
View File
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { HorizontalDotsIcon } from '@shared/icons'; import { HorizontalDotsIcon } from '@shared/icons';
import { Logout } from '@shared/logout';
export function AccountMoreActions({ pubkey }: { pubkey: string }) { export function AccountMoreActions({ pubkey }: { pubkey: string }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -18,7 +19,7 @@ export function AccountMoreActions({ pubkey }: { pubkey: string }) {
</button> </button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Portal> <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> <DropdownMenu.Item asChild>
<Link <Link
to={`/users/${pubkey}`} to={`/users/${pubkey}`}
@@ -44,9 +45,7 @@ export function AccountMoreActions({ pubkey }: { pubkey: string }) {
</Link> </Link>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button className="inline-flex h-10 items-center rounded-lg px-2 text-sm font-medium text-white hover:bg-white/10"> <Logout />
Logout
</button>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
+2 -2
View File
@@ -2,14 +2,14 @@ import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons'; import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader'; import { useNostr } from '@utils/hooks/useNostr';
export function AvatarUploader({ export function AvatarUploader({
setPicture, setPicture,
}: { }: {
setPicture: Dispatch<SetStateAction<string>>; setPicture: Dispatch<SetStateAction<string>>;
}) { }) {
const upload = useImageUploader(); const { upload } = useNostr();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadAvatar = async () => { const uploadAvatar = async () => {
+8 -9
View File
@@ -2,23 +2,21 @@ import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons'; import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader'; import { useNostr } from '@utils/hooks/useNostr';
export function BannerUploader({ export function BannerUploader({
setBanner, setBanner,
}: { }: {
setBanner: Dispatch< setBanner: Dispatch<SetStateAction<string>>;
SetStateAction<{ url: undefined | string; error?: undefined | string }>
>;
}) { }) {
const upload = useImageUploader(); const { upload } = useNostr();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadBanner = async () => { const uploadBanner = async () => {
setLoading(true); setLoading(true);
const image = await upload(null); const image = await upload(null);
if (image.url) { if (image.url) {
setBanner(image); setBanner(image.url);
} }
setLoading(false); setLoading(false);
}; };
@@ -27,13 +25,14 @@ export function BannerUploader({
<button <button
type="button" type="button"
onClick={() => uploadBanner()} 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 ? ( {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> </button>
); );
} }
+32 -41
View File
@@ -1,32 +1,28 @@
import { message } from '@tauri-apps/api/dialog';
import Image from '@tiptap/extension-image'; import Image from '@tiptap/extension-image';
import Mention from '@tiptap/extension-mention';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, useEditor } from '@tiptap/react'; import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import { convert } from 'html-to-text'; import { convert } from 'html-to-text';
import { nip19 } from 'nostr-tools';
import { useState } from 'react'; import { useState } from 'react';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { Suggestion } from '@shared/composer'; import { MediaUploader, MentionPopup } from '@shared/composer';
import { CancelIcon, LoaderIcon, PlusCircleIcon } from '@shared/icons'; import { CancelIcon, LoaderIcon } from '@shared/icons';
import { MentionNote } from '@shared/notes'; import { MentionNote } from '@shared/notes';
import { useComposer } from '@stores/composer'; import { useComposer } from '@stores/composer';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
import { useImageUploader } from '@utils/hooks/useUploader';
import { sendNativeNotification } from '@utils/notification'; import { sendNativeNotification } from '@utils/notification';
export function Composer() { export function Composer() {
const { publish } = useNostr(); const [loading, setLoading] = useState<boolean>(false);
const [status, setStatus] = useState<null | 'loading' | 'done'>(null);
const [reply, clearReply] = useComposer((state) => [state.reply, state.clearReply]); const [reply, clearReply] = useComposer((state) => [state.reply, state.clearReply]);
const expand = useComposer((state) => state.expand) const { publish } = useNostr();
const upload = useImageUploader();
const expand = useComposer((state) => state.expand);
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
@@ -35,12 +31,6 @@ export function Composer() {
}, },
}), }),
Placeholder.configure({ placeholder: 'Type something...' }), Placeholder.configure({ placeholder: 'Type something...' }),
Mention.configure({
suggestion: Suggestion,
renderLabel({ node }) {
return `nostr:${nip19.npubEncode(node.attrs.id.pubkey)} `;
},
}),
Image.configure({ Image.configure({
HTMLAttributes: { HTMLAttributes: {
class: class:
@@ -48,26 +38,22 @@ export function Composer() {
}, },
}), }),
], ],
content: '', content: JSON.parse(localStorage.getItem('editor-content') || '{}'),
editorProps: { editorProps: {
attributes: { attributes: {
class: 'h-full markdown break-all overflow-y-auto outline-none pr-2', class: 'h-full markdown break-all overflow-y-auto outline-none pr-2',
}, },
}, },
onUpdate: ({ editor }) => {
const jsonContent = JSON.stringify(editor.getJSON());
localStorage.setItem('editor-content', jsonContent);
},
}); });
const uploadImage = async (file?: string) => {
const image = await upload(file, true);
if (image.url) {
editor.commands.setImage({ src: image.url });
editor.commands.createParagraphNear();
}
};
const submit = async () => { const submit = async () => {
setStatus('loading');
try { try {
setLoading(true);
// get plaintext content // get plaintext content
const html = editor.getHTML(); const html = editor.getHTML();
const serializedContent = convert(html, { const serializedContent = convert(html, {
@@ -108,18 +94,19 @@ export function Composer() {
await publish({ content: serializedContent, kind: 1, tags }); await publish({ content: serializedContent, kind: 1, tags });
// send native notifiation // send native notifiation
await sendNativeNotification('Publish post successfully'); await sendNativeNotification('Post has been published successfully.');
// update state // update state
setStatus('done'); setLoading(false);
// reset editor // reset editor
editor.commands.clearContent(); editor.commands.clearContent();
// reset reply
if (reply.id) { if (reply.id) {
clearReply(); clearReply();
} }
} catch { } catch {
setStatus(null); setLoading(false);
console.log('failed to publish'); await message('Publishing post failed.', { title: 'Lume', type: 'error' });
} }
}; };
@@ -136,7 +123,10 @@ export function Composer() {
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
className={twMerge('scrollbar-hide markdown break-all max-h-[500px] overflow-y-auto outline-none pr-2', expand ? 'min-h-[500px]' : 'min-h-[120px]')} className={twMerge(
'scrollbar-hide markdown max-h-[500px] overflow-y-auto break-all pr-2 outline-none',
expand ? 'min-h-[500px]' : reply.id ? 'min-h-min' : 'min-h-[120px]'
)}
/> />
{reply.id && ( {reply.id && (
<div className="relative"> <div className="relative">
@@ -152,17 +142,18 @@ export function Composer() {
)} )}
</div> </div>
</div> </div>
<div className="flex items-center justify-between bg-white/5 rounded-b-xl p-2 border-t border-white/10"> <div className="flex items-center justify-between rounded-b-xl border-t border-white/10 bg-white/5 p-2">
<div className="inline-flex items-center gap-1">
<MediaUploader editor={editor} />
<MentionPopup editor={editor} />
</div>
<button <button
type="button" onClick={() => submit()}
onClick={() => uploadImage()} disabled={editor && editor.isEmpty}
className="ml-2 inline-flex h-10 w-10 items-center justify-center rounded-lg backdrop-blur-xl hover:bg-white/10" className="inline-flex h-10 w-20 items-center justify-center rounded-lg bg-fuchsia-500 px-2 font-semibold hover:bg-fuchsia-600 disabled:opacity-50"
> >
<PlusCircleIcon className="h-5 w-5 text-white" /> {loading === true ? (
</button> <LoaderIcon className="h-5 w-5 animate-spin text-white" />
<button onClick={() => submit()} className="inline-flex items-center justify-center w-max px-8 rounded-lg font-bold h-10 bg-fuchsia-500 hover:bg-fuchsia-600">
{status === 'loading' ? (
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
) : ( ) : (
'Post' 'Post'
)} )}
@@ -1,6 +1,6 @@
export * from './user'; export * from './user';
export * from './modal'; export * from './modal';
export * from './composer'; export * from './composer';
export * from './mention/list';
export * from './mention/item'; export * from './mention/item';
export * from './mention/suggestion'; export * from './mention/popup';
export * from './mediaUploader';
+62
View File
@@ -0,0 +1,62 @@
import { message } from '@tauri-apps/api/dialog';
import { UnlistenFn, listen } from '@tauri-apps/api/event';
import { Editor } from '@tiptap/react';
import { useEffect, useState } from 'react';
import { MediaIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
export function MediaUploader({ editor }: { editor: Editor }) {
const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async (file?: string) => {
try {
// start loading
setLoading(true);
const image = await upload(file, true);
if (image.url) {
editor.commands.setImage({ src: image.url });
editor.commands.createParagraphNear();
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
useEffect(() => {
let unlisten: UnlistenFn;
async function listenDnD() {
unlisten = await listen('tauri://file-drop', (event) => {
uploadToNostrBuild(event.payload[0]);
});
}
// start listen drag and drop event
listenDnD();
// clean up
return () => {
unlisten();
};
}, []);
return (
<button
type="button"
onClick={() => uploadToNostrBuild()}
className="ml-2 inline-flex h-10 w-max items-center justify-center gap-1.5 rounded-lg px-2 text-sm font-medium text-white/80 hover:bg-white/10 hover:backdrop-blur-xl"
>
<MediaIcon className="h-5 w-5 text-white/80" />
{loading ? 'Uploading...' : 'Add media'}
</button>
);
}
+23 -9
View File
@@ -1,22 +1,36 @@
import { Image } from '@shared/image'; import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey'; import { displayNpub } from '@utils/shortenKey';
import { Profile } from '@utils/types';
export function MentionItem({ profile }: { profile: Profile }) { export function MentionItem({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return (
<div className="flex items-center gap-2">
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded 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 ( return (
<div className="flex items-center gap-2"> <div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-white/10">
<Image <Image
src={profile.picture || profile.image} src={user.picture || user.image}
alt={profile.pubkey} alt={pubkey}
className="shirnk-0 h-8 w-8 rounded-md object-cover" className="shirnk-0 h-8 w-8 rounded-md object-cover"
/> />
<div className="flex flex-col gap-px"> <div className="flex flex-col items-start gap-px">
<h5 className="max-w-[15rem] text-sm font-medium leading-none text-white"> <h5 className="max-w-[10rem] truncate text-sm font-medium leading-none text-white">
{profile.ident} {user.display_name || user.displayName || user.name}
</h5> </h5>
<span className="text-sm leading-none text-white/50"> <span className="text-sm leading-none text-white/50">
{displayNpub(profile.pubkey, 16)} {displayNpub(pubkey, 16)}
</span> </span>
</div> </div>
</div> </div>
-75
View File
@@ -1,75 +0,0 @@
import { NDKUserProfile } from '@nostr-dev-kit/ndk';
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { MentionItem } from '@shared/composer';
export const MentionList = forwardRef((props: any, ref: any) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const item = props.items[index];
if (item) {
props.command({ id: item });
}
};
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
downHandler();
return true;
}
if (event.key === 'Enter') {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="flex w-[250px] flex-col rounded-xl border-t border-zinc-700/50 bg-zinc-800 px-3 py-3">
{props.items.length ? (
props.items.map((item: NDKUserProfile, index: number) => (
<button
className={twMerge(
'h-11 w-full rounded-lg px-2 text-start text-sm font-medium hover:bg-zinc-700',
`${index === selectedIndex ? 'is-selected' : ''}`
)}
key={index}
onClick={() => selectItem(index)}
>
<MentionItem profile={item} />
</button>
))
) : (
<div>No result</div>
)}
</div>
);
});
MentionList.displayName = 'MentionList';
+38
View File
@@ -0,0 +1,38 @@
import * as Popover from '@radix-ui/react-popover';
import { Editor } from '@tiptap/react';
import { nip19 } from 'nostr-tools';
import { useStorage } from '@libs/storage/provider';
import { MentionItem } from '@shared/composer';
import { MentionIcon } from '@shared/icons';
export function MentionPopup({ editor }: { editor: Editor }) {
const { db } = useStorage();
const insertMention = (pubkey: string) => {
editor.commands.insertContent(`nostr:${nip19.npubEncode(pubkey)}`);
};
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className="inline-flex h-10 w-10 items-center justify-center rounded-lg hover:bg-white/10 hover:backdrop-blur-xl"
>
<MentionIcon className="h-5 w-5 text-white/80" />
</button>
</Popover.Trigger>
<Popover.Content className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg bg-white/10 backdrop-blur-xl focus:outline-none">
<div className="flex flex-col gap-1 py-1">
{db.account.follows.map((item) => (
<button key={item} type="button" onClick={() => insertMention(item)}>
<MentionItem pubkey={item} />
</button>
))}
</div>
</Popover.Content>
</Popover.Root>
);
}
@@ -1,68 +0,0 @@
import { ReactRenderer } from '@tiptap/react';
import tippy from 'tippy.js';
import { MentionList } from '@shared/composer';
export const Suggestion = {
items: async ({ query }) => {
const users = [];
return users
.filter((item) => item.ident.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 5);
},
render: () => {
let component;
let popup;
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
});
},
onUpdate(props) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup[0].hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
popup[0].destroy();
component.destroy();
},
};
},
};
+1 -1
View File
@@ -37,7 +37,7 @@ export function ComposerModal() {
</button> </button>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Portal className="relative z-10"> <Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" /> <Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div <div
className={twMerge( className={twMerge(
+2 -3
View File
@@ -1,6 +1,7 @@
import { Image } from '@shared/image'; import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function ComposerUser({ pubkey }: { pubkey: string }) { export function ComposerUser({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey); const { user } = useProfile(pubkey);
@@ -13,9 +14,7 @@ export function ComposerUser({ pubkey }: { pubkey: string }) {
className="h-10 w-10 shrink-0 rounded-lg" className="h-10 w-10 shrink-0 rounded-lg"
/> />
<h5 className="text-base font-semibold leading-none text-white"> <h5 className="text-base font-semibold leading-none text-white">
{user?.nip05 || user?.name || ( {user?.name || user?.display_name || displayNpub(pubkey, 16)}
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
)}
</h5> </h5>
</div> </div>
); );
+28
View File
@@ -0,0 +1,28 @@
import { HTMLProps, ReactNode, useCallback } from 'react';
import { twMerge } from 'tailwind-merge';
import { useStorage } from '@libs/storage/provider';
export function Frame({
children,
className,
lighter,
}: {
children: ReactNode;
className: HTMLProps<HTMLElement>['className'];
lighter?: boolean;
}) {
const { db } = useStorage();
const platformStyles = useCallback(() => {
switch (db.platform) {
case 'darwin':
if (lighter) return 'bg-black/80';
return 'bg-black/90';
default:
return 'bg-black';
}
}, []);
return <div className={twMerge(className, platformStyles())}>{children}</div>;
}
+74
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>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function ArticleIcon(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
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M16.25 12V4.75a1 1 0 00-1-1H3.75a1 1 0 00-1 1v13a2.5 2.5 0 002.5 2.5H18.5M16.25 12v5.75a2.5 2.5 0 005 0V13a1 1 0 00-1-1h-4zm-9.5 3.75h5.5m-5.5-8h5.5v4.5h-5.5v-4.5z"
></path>
</svg>
);
}
+2 -3
View File
@@ -13,10 +13,9 @@ export function FileIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5" strokeWidth="1.5"
d="M8.75 6.75h6.5m-6.5 4h6.5m-6.5 4h2.5m-5.5 6.5h12.5a1 1 0 001-1V3.75a1 1 0 00-1-1H5.75a1 1 0 00-1 1v16.5a1 1 0 001 1z" d="M12.75 3.25v5a1 1 0 001 1h5m-10 4h3.5m-3.5 4h6.5m-9.5-14.5h6.586a1 1 0 01.707.293l5.914 5.914a1 1 0 01.293.707V20.25a1 1 0 01-1 1H5.75a1 1 0 01-1-1V3.75a1 1 0 011-1z"
/> ></path>
</svg> </svg>
); );
} }
+22
View File
@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function FollowsIcon(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
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M17.75 19.25h3.673c.581 0 1.045-.496.947-1.07-.531-3.118-2.351-5.43-5.37-5.43-.446 0-.866.05-1.26.147M11.25 7a3.25 3.25 0 11-6.5 0 3.25 3.25 0 016.5 0zm8.5.5a2.75 2.75 0 11-5.5 0 2.75 2.75 0 015.5 0zM1.87 19.18c.568-3.68 2.647-6.43 6.13-6.43 3.482 0 5.561 2.75 6.13 6.43.088.575-.375 1.07-.956 1.07H2.825c-.58 0-1.043-.495-.955-1.07z"
></path>
</svg>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function GroupFeedsIcon(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
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M14.425 13.18c3.361-1.396 7.598.605 8.454 6.003.09.573-.372 1.067-.952 1.067H16.75M10.75 7a3.25 3.25 0 11-6.5 0 3.25 3.25 0 016.5 0zm9 0a3.25 3.25 0 11-6.5 0 3.25 3.25 0 016.5 0zm-6.966 13.25H2.072c-.58 0-1.042-.497-.951-1.07 1.362-8.573 11.252-8.573 12.614 0 .091.573-.371 1.07-.951 1.07z"
></path>
</svg>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export function HashtagIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElem
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.5" strokeWidth="1.5"
d="M8.75 3.75l-2 16.5m10.5-16.5l-2 16.5M3.75 7.75h16.5m0 8.5H3.75" d="M8.75 3.75l-2 16.5m10.5-16.5l-2 16.5M3.75 7.75h16.5m0 8.5H3.75"
/> ></path>
</svg> </svg>
); );
} }
+8
View File
@@ -56,3 +56,11 @@ export * from './expand';
export * from './focus'; export * from './focus';
export * from './chevronUp'; export * from './chevronUp';
export * from './secure'; export * from './secure';
export * from './verified';
export * from './mention';
export * from './groupFeeds';
export * from './article';
export * from './follows';
export * from './alby';
export * from './stars';
export * from './nwc';
+22
View File
@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function MentionIcon(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
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M11.85 13.251c-3.719.065-6.427 2.567-7.18 5.915-.13.575.338 1.084.927 1.084h6.901m-.647-6.999l.147-.001c.353 0 .696.022 1.03.064m-1.177-.063a7.889 7.889 0 00-1.852.249m3.028-.186c.334.042.658.104.972.186m-.972-.186a7.475 7.475 0 011.972.524m3.25 1.412v3m0 0v3m0-3h-3m3 0h3m-5.5-11.75a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
></path>
</svg>
);
}
+22
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>
);
}
+19
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>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { SVGProps } from 'react';
export function VerifiedIcon(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="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm3.58 7.975a.75.75 0 00-1.16-.95l-3.976 4.859L9.03 12.47a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.11-.055l4.5-5.5z"
clipRule="evenodd"
/>
</svg>
);
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { Outlet, ScrollRestoration } from 'react-router-dom'; import { Outlet, ScrollRestoration } from 'react-router-dom';
import { Frame } from '@shared/frame';
import { Navigation } from '@shared/navigation'; import { Navigation } from '@shared/navigation';
export function AppLayout() { export function AppLayout() {
@@ -8,14 +9,14 @@ export function AppLayout() {
<div className="shrink-0"> <div className="shrink-0">
<Navigation /> <Navigation />
</div> </div>
<div className="h-full w-full flex-1 bg-black/90 backdrop-blur-xl"> <Frame className="h-full w-full flex-1">
<Outlet /> <Outlet />
<ScrollRestoration <ScrollRestoration
getKey={(location) => { getKey={(location) => {
return location.pathname; return location.pathname;
}} }}
/> />
</div> </Frame>
</div> </div>
); );
} }
+4 -2
View File
@@ -1,10 +1,12 @@
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { Frame } from '@shared/frame';
export function AuthLayout() { export function AuthLayout() {
return ( return (
<div className="relative h-screen w-screen bg-black/90 backdrop-blur-xl"> <Frame className="relative h-screen w-screen">
<div className="absolute left-0 top-0 z-50 h-16 w-full" data-tauri-drag-region /> <div className="absolute left-0 top-0 z-50 h-16 w-full" data-tauri-drag-region />
<Outlet /> <Outlet />
</div> </Frame>
); );
} }
+4 -2
View File
@@ -1,10 +1,12 @@
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { Frame } from '@shared/frame';
export function NoteLayout() { export function NoteLayout() {
return ( return (
<div className="relative h-screen w-screen bg-black/90 backdrop-blur-xl"> <Frame className="relative h-screen w-screen">
<div className="absolute left-0 top-0 z-50 h-16 w-full" data-tauri-drag-region /> <div className="absolute left-0 top-0 z-50 h-16 w-full" data-tauri-drag-region />
<Outlet /> <Outlet />
</div> </Frame>
); );
} }
+3 -2
View File
@@ -1,12 +1,13 @@
import { Link, NavLink, Outlet, ScrollRestoration } from 'react-router-dom'; import { Link, NavLink, Outlet, ScrollRestoration } from 'react-router-dom';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { Frame } from '@shared/frame';
import { ArrowLeftIcon, SecureIcon, SettingsIcon } from '@shared/icons'; import { ArrowLeftIcon, SecureIcon, SettingsIcon } from '@shared/icons';
export function SettingsLayout() { export function SettingsLayout() {
return ( return (
<div className="flex h-screen w-screen"> <div className="flex h-screen w-screen">
<div className="relative flex h-full w-[232px] flex-col bg-black/80"> <Frame className="relative flex h-full w-[232px] flex-col" lighter>
<div data-tauri-drag-region className="h-11 w-full shrink-0" /> <div data-tauri-drag-region className="h-11 w-full shrink-0" />
<div className="scrollbar-hide flex h-full flex-1 flex-col gap-2 overflow-y-auto pb-32"> <div className="scrollbar-hide flex h-full flex-1 flex-col gap-2 overflow-y-auto pb-32">
<div className="inline-flex items-center gap-2 border-l-2 border-transparent pl-4"> <div className="inline-flex items-center gap-2 border-l-2 border-transparent pl-4">
@@ -55,7 +56,7 @@ export function SettingsLayout() {
</NavLink> </NavLink>
</div> </div>
</div> </div>
</div> </Frame>
<div className="h-full w-full flex-1 bg-black/90 backdrop-blur-xl"> <div className="h-full w-full flex-1 bg-black/90 backdrop-blur-xl">
<Outlet /> <Outlet />
<ScrollRestoration <ScrollRestoration
+46 -59
View File
@@ -1,78 +1,65 @@
import * as Dialog from '@radix-ui/react-dialog'; import * as AlertDialog from '@radix-ui/react-alert-dialog';
import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom';
import { relaunch } from '@tauri-apps/plugin-process';
import { CancelIcon, LogoutIcon } from '@shared/icons'; import { useStorage } from '@libs/storage/provider';
import { useStronghold } from '@stores/stronghold';
export function Logout() { export function Logout() {
const queryClient = useQueryClient(); const { db } = useStorage();
const navigate = useNavigate();
const clearPrivkey = useStronghold((state) => state.clearPrivkey);
const logout = async () => { const logout = async () => {
// reset database // remove account
// await removeAll(); db.accountLogout();
// reset react query // clear privkey in session storage
queryClient.clear(); clearPrivkey();
// navigate // redirect to welcome screen
await relaunch(); navigate('/auth/welcome');
}; };
return ( return (
<Dialog.Root> <AlertDialog.Root>
<Dialog.Trigger asChild> <AlertDialog.Trigger asChild>
<button <button
type="button" type="button"
aria-label="Logout" className="inline-flex h-10 items-center rounded-lg px-2 text-sm font-medium text-white hover:bg-white/10"
className="inline-flex h-9 w-9 transform items-center justify-center rounded-md bg-white/20 active:translate-y-1"
> >
<LogoutIcon className="h-4 w-4 text-white" /> Logout
</button> </button>
</Dialog.Trigger> </AlertDialog.Trigger>
<Dialog.Portal className="relative z-10"> <AlertDialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" /> <AlertDialog.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"> <AlertDialog.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 max-w-md rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-6"> <div className="flex flex-col gap-2 border-b border-white/5 px-5 py-4">
<div className="flex flex-col gap-2"> <AlertDialog.Title className="text-lg font-semibold leading-none text-white">
<div className="flex items-center justify-between"> Are you sure!
<Dialog.Title className="text-lg font-semibold leading-none text-white"> </AlertDialog.Title>
Are you sure! <AlertDialog.Description className="text-sm leading-tight text-white/50">
</Dialog.Title> You can always log back in at any time. If you just want to switch
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10"> accounts, you can do that by adding an existing account.
<CancelIcon className="h-4 w-4 text-white/50" /> </AlertDialog.Description>
</Dialog.Close>
</div>
<Dialog.Description className="text-sm leading-tight text-white/50">
<p className="mb-2">
When logout, all local data will be wiped, and restart app then you
need to start onboarding process again when you log in.
</p>
<p>
In the next version, Lume will support multi account, then you can
switch between all account s instead of logout
</p>
</Dialog.Description>
</div>
</div> </div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5"> <div className="flex justify-end gap-2 px-5 py-3">
<div className="flex items-center gap-2"> <AlertDialog.Cancel asChild>
<button <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">
type="button"
className="inline-flex h-9 items-center justify-center rounded-md px-3 text-sm font-medium text-white/50 backdrop-blur-xl hover:bg-white/10"
>
Cancel Cancel
</button> </button>
<button </AlertDialog.Cancel>
type="button" <button
onClick={() => logout()} type="button"
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-3 text-sm font-medium text-white hover:bg-red-600" onClick={() => logout()}
> 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"
Confirm >
</button> Logout
</div> </button>
</div> </div>
</div> </div>
</Dialog.Content> </AlertDialog.Content>
</Dialog.Portal> </AlertDialog.Portal>
</Dialog.Root> </AlertDialog.Root>
); );
} }
+76 -12
View File
@@ -4,18 +4,33 @@ import { twMerge } from 'tailwind-merge';
import { ChatsList } from '@app/chats/components/list'; import { ChatsList } from '@app/chats/components/list';
import { useStorage } from '@libs/storage/provider';
import { ActiveAccount } from '@shared/accounts/active'; import { ActiveAccount } from '@shared/accounts/active';
import { ComposerModal } from '@shared/composer'; import { ComposerModal } from '@shared/composer';
import { BellIcon, NavArrowDownIcon, SpaceIcon } from '@shared/icons'; import { Frame } from '@shared/frame';
import { BellIcon, NavArrowDownIcon, NwcIcon, SpaceIcon } from '@shared/icons';
import { useActivities } from '@stores/activities';
import { useSidebar } from '@stores/sidebar'; import { useSidebar } from '@stores/sidebar';
import { compactNumber } from '@utils/number';
export function Navigation() { export function Navigation() {
const { db } = useStorage();
const [totalNewActivities] = useActivities((state) => [state.totalNewActivities]);
const [chats, toggleChats] = useSidebar((state) => [state.chats, state.toggleChats]); const [chats, toggleChats] = useSidebar((state) => [state.chats, state.toggleChats]);
const [integrations, toggleIntegrations] = useSidebar((state) => [
state.integrations,
state.toggleIntegrations,
]);
return ( return (
<div className="relative flex h-full w-[232px] flex-col bg-black/80"> <Frame className="relative flex h-full w-[232px] flex-col" lighter>
<div data-tauri-drag-region className="h-11 w-full shrink-0" /> {db.platform === 'darwin' ? (
<div data-tauri-drag-region className="h-11 w-full shrink-0" />
) : null}
<div className="scrollbar-hide flex h-full flex-1 flex-col gap-6 overflow-y-auto pb-32"> <div className="scrollbar-hide flex h-full flex-1 flex-col gap-6 overflow-y-auto pb-32">
<div className="flex flex-col pr-2"> <div className="flex flex-col pr-2">
<ComposerModal /> <ComposerModal />
@@ -27,7 +42,7 @@ export function Navigation() {
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2', 'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
isActive isActive
? 'border-fuchsia-500 bg-white/5 text-white' ? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/80' : 'border-transparent text-white/70'
) )
} }
> >
@@ -41,19 +56,68 @@ export function Navigation() {
preventScrollReset={true} preventScrollReset={true}
className={({ isActive }) => className={({ isActive }) =>
twMerge( twMerge(
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2', 'flex h-10 items-center justify-between rounded-r-lg border-l-2 pl-4 pr-2',
isActive isActive
? 'border-fuchsia-500 bg-white/5 text-white' ? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/80' : '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"> <div className="flex items-center gap-2.5">
<BellIcon className="h-4 w-4 text-white" /> {totalNewActivities > 0 ? (
</span> <div className="relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-fuchsia-500/20 backdrop-blur-xl">
Notifications <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>
</NavLink> </NavLink>
</div> </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}> <Collapsible.Root open={chats} onOpenChange={toggleChats}>
<div className="flex flex-col gap-1 pr-2"> <div className="flex flex-col gap-1 pr-2">
<Collapsible.Trigger asChild> <Collapsible.Trigger asChild>
@@ -77,9 +141,9 @@ export function Navigation() {
</div> </div>
</Collapsible.Root> </Collapsible.Root>
</div> </div>
<div className="shrink-0"> <div className="relative shrink-0">
<ActiveAccount /> <ActiveAccount />
</div> </div>
</div> </Frame>
); );
} }
+75
View File
@@ -0,0 +1,75 @@
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';
interface NIP05 {
names: {
[key: string]: string;
};
}
export const NIP05 = memo(function NIP05({
pubkey,
nip05,
className,
}: {
pubkey: string;
nip05: string;
className?: string;
}) {
const { status, data } = useQuery(
['nip05', nip05],
async () => {
try {
const localPath = nip05.split('@')[0];
const service = nip05.split('@')[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
const res = await fetch(verifyURL, {
method: 'GET',
timeout: 10,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
});
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
const data = res.data as NIP05;
if (data.names) {
if (data.names[localPath] !== pubkey) return false;
return true;
}
return false;
} catch (e) {
throw new Error(`Failed to verify NIP-05, error: ${e}`);
}
},
{
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
}
);
if (status === 'loading') {
<div className="h-3 w-20 animate-pulse rounded bg-white/10" />;
}
return (
<div className={twMerge('leadning-none inline-flex items-center gap-1', className)}>
<p>{nip05}</p>
<div className="shrink-0">
{data === true ? (
<VerifiedIcon className="h-3 w-3 text-green-500" />
) : (
<UnverifiedIcon className="h-3 w-3 text-red-500" />
)}
</div>
</div>
);
});
+4 -4
View File
@@ -28,12 +28,12 @@ export function NoteActions({
return ( return (
<Tooltip.Provider> <Tooltip.Provider>
<div className="-ml-1 mt-4 inline-flex w-full items-center"> <div className="-ml-1 mt-2 inline-flex w-full items-center">
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
<NoteReply id={id} pubkey={pubkey} root={root} /> <NoteReply id={id} pubkey={pubkey} root={root} />
<NoteReaction id={id} pubkey={pubkey} /> <NoteReaction id={id} pubkey={pubkey} />
<NoteRepost id={id} pubkey={pubkey} /> <NoteRepost id={id} pubkey={pubkey} />
<NoteZap id={id} /> <NoteZap id={id} pubkey={pubkey} />
</div> </div>
{extraButtons && ( {extraButtons && (
<> <>
@@ -50,7 +50,7 @@ export function NoteActions({
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content className="-left-10 select-none rounded-md bg-black px-3.5 py-1.5 text-sm leading-none text-white will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"> <Tooltip.Content className="-left-10 select-none rounded-md bg-black px-3.5 py-1.5 text-sm leading-none text-white will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
Open thread Focus
<Tooltip.Arrow className="fill-black" /> <Tooltip.Arrow className="fill-black" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Portal> </Tooltip.Portal>
@@ -61,7 +61,7 @@ export function NoteActions({
type="button" type="button"
onClick={() => onClick={() =>
setWidget(db, { setWidget(db, {
kind: WidgetKinds.thread, kind: WidgetKinds.local.thread,
title: 'Thread', title: 'Thread',
content: id, content: id,
}) })
+3 -4
View File
@@ -1,6 +1,6 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import * as Tooltip from '@radix-ui/react-tooltip'; import * as Tooltip from '@radix-ui/react-tooltip';
import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { writeText } from '@tauri-apps/api/clipboard';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/nip19'; import { EventPointer } from 'nostr-tools/lib/nip19';
import { useState } from 'react'; import { useState } from 'react';
@@ -18,8 +18,7 @@ export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
const copyLink = async () => { const copyLink = async () => {
await writeText( await writeText(
'https://nostr.com/' + 'https://njump.me/' + nip19.neventEncode({ id: id, author: pubkey } as EventPointer)
nip19.neventEncode({ id: id, author: pubkey } as EventPointer)
); );
setOpen(false); setOpen(false);
}; };
@@ -45,7 +44,7 @@ export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
</Tooltip.Portal> </Tooltip.Portal>
</Tooltip.Root> </Tooltip.Root>
<DropdownMenu.Portal> <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> <DropdownMenu.Item asChild>
<button <button
type="button" type="button"
+2 -1
View File
@@ -1,3 +1,4 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import * as Popover from '@radix-ui/react-popover'; import * as Popover from '@radix-ui/react-popover';
import { useState } from 'react'; import { useState } from 'react';
@@ -44,7 +45,7 @@ export function NoteReaction({ id, pubkey }: { id: string; pubkey: string }) {
const event = await publish({ const event = await publish({
content: content, content: content,
kind: 7, kind: NDKKind.Reaction,
tags: [ tags: [
['e', id], ['e', id],
['p', pubkey], ['p', pubkey],
+35 -12
View File
@@ -1,25 +1,39 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import * as AlertDialog from '@radix-ui/react-alert-dialog'; import * as AlertDialog from '@radix-ui/react-alert-dialog';
import * as Tooltip from '@radix-ui/react-tooltip'; import * as Tooltip from '@radix-ui/react-tooltip';
import { message } from '@tauri-apps/api/dialog';
import { useState } from 'react'; 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 { useNostr } from '@utils/hooks/useNostr';
import { sendNativeNotification } from '@utils/notification';
export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) { export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
const { publish } = useNostr(); const { publish } = useNostr();
const { relayUrls } = useNDK();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const submit = async () => { const submit = async () => {
setIsLoading(true);
const tags = [ const tags = [
['e', id, 'wss://relayable.org', 'root'], ['e', id, relayUrls[0], 'root'],
['p', pubkey], ['p', pubkey],
]; ];
const event = await publish({ content: '', kind: 6, tags: tags }); const event = await publish({ content: '', kind: NDKKind.Repost, tags: tags });
if (event) { if (event) {
setOpen(false); setOpen(false);
setIsRepost(true);
await sendNativeNotification('Reposted successfully', 'Lume');
} else { } 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" type="button"
className="group inline-flex h-7 w-7 items-center justify-center" 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> </button>
</AlertDialog.Trigger> </AlertDialog.Trigger>
</Tooltip.Trigger> </Tooltip.Trigger>
@@ -44,30 +63,34 @@ export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
</Tooltip.Portal> </Tooltip.Portal>
</Tooltip.Root> </Tooltip.Root>
<AlertDialog.Portal className="relative z-10"> <AlertDialog.Portal className="relative z-10">
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" /> <AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<AlertDialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <AlertDialog.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 max-w-md rounded-xl bg-white/10 backdrop-blur-xl">
<div className="flex flex-col gap-2 border-b border-white/5 px-5 py-4"> <div className="flex flex-col gap-2 border-b border-white/5 px-5 py-4">
<AlertDialog.Title className="text-lg font-semibold leading-none text-white"> <AlertDialog.Title className="text-lg font-semibold leading-none text-white">
Confirm repost this post? Confirm repost this post?
</AlertDialog.Title> </AlertDialog.Title>
<AlertDialog.Description className="text-sm leading-none text-white/50"> <AlertDialog.Description className="text-sm leading-tight text-white/50">
Reposted post will be visible to your followers, and you cannot undo this Reposted post will be visible to your followers, and you cannot undo this
action. action.
</AlertDialog.Description> </AlertDialog.Description>
</div> </div>
<div className="flex justify-end gap-6 px-5 py-3"> <div className="flex justify-end gap-2 px-3 py-3">
<AlertDialog.Cancel asChild> <AlertDialog.Cancel asChild>
<button className="inline-flex h-11 items-center justify-center rounded-lg bg-white/10 px-4 font-medium leading-none text-white outline-none 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 Cancel
</button> </button>
</AlertDialog.Cancel> </AlertDialog.Cancel>
<button <button
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
className="inline-flex h-11 items-center justify-center rounded-lg bg-fuchsia-500 px-4 font-medium leading-none text-white outline-none" 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> </button>
</div> </div>
</div> </div>
+179 -107
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 * as Dialog from '@radix-ui/react-dialog';
import { message } from '@tauri-apps/api/dialog';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { twMerge } from 'tailwind-merge'; import CurrencyInput from 'react-currency-input-field';
import TextareaAutosize from 'react-textarea-autosize';
import { CancelIcon, ZapIcon } from '@shared/icons'; import { CancelIcon, ZapIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useEvent } from '@utils/hooks/useEvent'; import { useEvent } from '@utils/hooks/useEvent';
import { useNostr } from '@utils/hooks/useNostr'; 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 { createZap } = useNostr();
const { user } = useProfile(pubkey);
const { data: event } = useEvent(id); 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 [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) => { const walletConnectURL = useStronghold((state) => state.walletConnectURL);
if (amount === num) return true; const nwc = useRef(null);
return false;
};
const createZapRequest = async () => { const createZapRequest = async () => {
// @ts-expect-error, todo: fix this try {
const res = await createZap(event as unknown as NostrEvent, amount); const zapAmount = parseInt(amount) * 1000;
if (res) setInvoice(res); 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 ( return (
<Dialog.Root> <Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild> <Dialog.Trigger asChild>
<button <button
type="button" type="button"
@@ -38,113 +100,123 @@ export function NoteZap({ id }: { id: string }) {
</button> </button>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Portal className="relative z-10"> <Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" /> <Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <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 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="inline-flex w-full shrink-0 items-center justify-between px-5 py-3">
<div className="flex flex-col items-center gap-1.5"> <div className="w-6" />
<Dialog.Title className="font-medium leading-none text-white"> <Dialog.Title className="text-center text-sm font-semibold leading-none text-white">
Zap (Beta) Send tip to {user?.display_name || user?.name}
</Dialog.Title> </Dialog.Title>
<Dialog.Description className="text-sm leading-none text-white/50"> <Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
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">
<CancelIcon className="h-4 w-4 text-white/50" /> <CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close> </Dialog.Close>
</div> </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 ? ( {!invoice ? (
<> <>
<div className="grid grid-cols-3 gap-3"> <div className="relative flex h-40 flex-col">
<button <div className="inline-flex h-full flex-1 items-center justify-center gap-1">
type="button" <CurrencyInput
onClick={() => setAmount(21000)} placeholder="0"
className={twMerge( defaultValue={'21'}
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10', value={amount}
`${selected(21000) && 'bg-white/10 backdrop-blur-xl'}` decimalsLimit={2}
)} min={0} // 0 sats
> max={10000} // 1M sats
<img className="h-12 w-12" src="/zap.png" alt="High Voltage" /> maxLength={10000} // 1M sats
<span className="text-sm font-medium leading-none text-white/80"> onValueChange={(value) => setAmount(value)}
21 sats 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> </span>
</button> </div>
<button <div className="inline-flex items-center justify-center gap-2">
type="button" <button
onClick={() => setAmount(69000)} type="button"
className={twMerge( onClick={() => setAmount('69')}
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10', 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"
`${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">
69 sats 69 sats
</span> </button>
</button> <button
<button type="button"
type="button" onClick={() => setAmount('100')}
onClick={() => setAmount(100000)} 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"
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">
100 sats 100 sats
</span> </button>
</button> <button
<button type="button"
type="button" onClick={() => setAmount('200')}
onClick={() => setAmount(200000)} 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"
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">
200 sats 200 sats
</span> </button>
</button> <button
<button type="button"
type="button" onClick={() => setAmount('500')}
onClick={() => setAmount(500000)} 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"
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">
500 sats 500 sats
</span> </button>
</button> <button
<button type="button"
type="button" onClick={() => setAmount('1000')}
onClick={() => setAmount(1000000)} 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"
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', 1K sats
`${selected(1000000) && 'bg-white/10 backdrop-blur-xl'}` </button>
)} </div>
>
<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>
</div> </div>
<div className="mt-4 flex w-full"> <div className="mt-4 flex w-full flex-col gap-2">
<button <TextareaAutosize
type="button" name="zapMessage"
onClick={() => createZapRequest()} value={zapMessage}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-fuchsia-500 px-4 hover:bg-fuchsia-600" onChange={(e) => setZapMessage(e.target.value)}
> spellCheck={false}
Create invoice autoComplete="off"
</button> 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> </div>
</> </>
) : ( ) : (

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