Compare commits

..

162 Commits

Author SHA1 Message Date
reya
6e5d0f0e76 chore: bump version 2024-06-24 10:06:12 +07:00
XIAO YU
f0712e5961 refactor: improve error handling (#216) 2024-06-24 07:19:36 +07:00
雨宮蓮
3fbd66dece Add bitcoin connect (#215)
* feat: add bitcoin connect

* feat: improve zap screen
2024-06-21 14:56:10 +07:00
reya
1283432632 feat: use native feature instead of react 2024-06-21 10:24:09 +07:00
reya
59eaaec903 feat: support content warning 2024-06-21 08:57:49 +07:00
雨宮蓮
4f0f210076 Add lazy carousel (#214)
* refactor: carousel

* feat: improve image carousel
2024-06-21 07:51:11 +07:00
reya
e4a317f038 chore: bump version 2024-06-20 13:23:05 +07:00
reya
9779d020c7 feat: improve list virtualization 2024-06-20 13:22:28 +07:00
雨宮蓮
f8280ec8ee fix: get replies function (#213) 2024-06-19 21:02:33 +07:00
XIAO YU
6c26f8967b chore: Refactor code for better performance and reliability (#212) 2024-06-19 14:36:59 +07:00
reya
e1424b851c fix: routes 2024-06-19 14:22:45 +07:00
reya
d14e609579 chore: bump version 2024-06-19 14:14:34 +07:00
reya
8c0627ff27 fix: crash on startup 2024-06-19 14:14:03 +07:00
雨宮蓮
18c133d096 Settings Manager (#211)
* refactor: landing screen

* fix: code debt

* feat: add settings screen

* chore: clean up

* feat: settings

* feat: small updates
2024-06-19 14:00:58 +07:00
reya
0061ecea78 feat: use native context menu in tray panel 2024-06-18 09:07:58 +07:00
reya
d01cf8319d feat: native context menu 2024-06-17 15:31:59 +07:00
雨宮蓮
843895d876 Make Lume Faster (#208)
* chore: fix some lint issues

* feat: refactor contact list

* feat: refactor relay hint

* feat: add missing commands

* feat: use new cache layer for react query

* feat: refactor column

* feat: improve relay hint

* fix: replace break with continue in parser

* refactor: publish function

* feat: add reply command

* feat: improve editor

* fix: quote

* chore: update deps

* refactor: note component

* feat: improve repost

* feat: improve cache

* fix: backup screen

* refactor: column manager
2024-06-17 13:52:06 +07:00
XIAO YU
7c99ed39e4 chore: handle unwrap err (#210) 2024-06-16 08:11:48 +07:00
雨宮蓮
71be59b2e9 Move the event parser and dedup functions to Rust (#206)
* feat: improve js parser

* feat: move parser and dedup to rust

* fix: parser

* fix: get event function

* feat: improve parser performance (#207)

* feat: improve parser performance

* feat: add test for video parsing

* feat: finish new parser

---------

Co-authored-by: XIAO YU <xyzmhx@gmail.com>
2024-06-12 08:27:53 +07:00
reya
1c20512ecc chore: update deps 2024-06-10 13:15:57 +07:00
雨宮蓮
90342c552f Customize Bootstrap Relays (#205)
* feat: add bootstrap relays file

* feat: add save bootstrap relays command

* feat: add customize bootstrap relays screen
2024-06-10 10:48:39 +07:00
reya
b396c8a695 feat: upgrade to rust-nostr 0.32 2024-06-08 08:00:02 +07:00
reya
6996e30889 chore: update github ci 2024-06-07 11:26:20 +07:00
reya
7ba793fad8 chore: bump version 2024-06-07 10:03:04 +07:00
reya
f11f836518 chore: update tray icon 2024-06-07 09:56:57 +07:00
reya
04fe0fcec8 feat: respect the relay hint 2024-06-07 09:07:33 +07:00
雨宮蓮
799835a629 Notification Panel (#200)
* feat: add tauri nspanel

* feat: add notification panel

* feat: move notification service to backend

* feat: add sync notification job

* feat: enable panel to join all spaces including fullscreen (#203)

* feat: fetch notification

* feat: listen for new notification

* feat: finish panel

---------

Co-authored-by: Victor Aremu <me@victorare.mu>
2024-06-06 14:32:30 +07:00
XIAO YU
4e7da4108b feat: Add get user following function (#202)
* feat: Add get user following function

* refactor: Refactor get_following function to use state and string public key

* feat: Update get_following function to use timeout duration

* feat: Fix connect_remote_account function to return remote_npub without conversion

* feat: Refactor get_following function to handle public key parsing errors

* Refactor get_followers function to handle public key parsing errors and use timeout duration
2024-06-05 13:24:32 +07:00
reya
7c7b082b3a fix: memory leak in image component 2024-06-03 07:32:34 +07:00
reya
38d6c51921 feat: use nostrdb for unix and rocksdb for windows 2024-06-02 08:16:59 +07:00
reya
1738cbdd97 chore: upgrade tauri 2024-06-01 14:54:35 +07:00
reya
2e885b76a1 feat: improve text wrap 2024-06-01 08:27:22 +07:00
reya
f94680e487 fix: column overlapped after change account 2024-05-31 15:25:47 +07:00
XIAO YU
c682a58842 chore: Remove unused modules and update metadata.rs (#199) 2024-05-31 12:53:35 +07:00
reya
921cf871ee chore: bump version 2024-05-31 08:54:53 +07:00
reya
d5b1593aca feat: improve nostr connect flow 2024-05-31 08:54:17 +07:00
reya
6676b4e2a4 Revert "chore: Update dependencies and add thiserror crate (#196)"
This reverts commit e254ee3203.
2024-05-30 15:40:44 +07:00
reya
5f30ddcfca Merge branch 'main' of github.com:lumehq/lume 2024-05-30 15:23:11 +07:00
reya
41d0de539d feat: revamp nostr connect flow 2024-05-30 15:21:33 +07:00
XIAO YU
e254ee3203 chore: Update dependencies and add thiserror crate (#196) 2024-05-30 07:12:46 +07:00
reya
6d42360549 chore: bump version 2024-05-29 14:47:22 +07:00
reya
70c5143445 feat: redirect to index after add account 2024-05-29 14:41:33 +07:00
reya
41b66b18f5 feat: lazy load onboarding videos 2024-05-29 14:11:30 +07:00
雨宮蓮
dda0720ed4 chore: update deps (#195) 2024-05-29 12:54:58 +07:00
reya
4b60b39119 chore: bump version 2024-05-29 10:34:32 +07:00
reya
d2e5122d5a feat: improve splash screen and notification service 2024-05-29 10:33:38 +07:00
reya
32f3315344 fix: editor crash on open 2024-05-28 14:43:34 +07:00
reya
5ca9444358 feat: add theme switcher 2024-05-26 16:11:27 +07:00
reya
4dc13385a5 feat: add option for relay hint 2024-05-26 08:23:29 +07:00
XIAO YU
b90ad1421f chore: update keys.rs and relay.rs (#193) 2024-05-26 07:28:16 +07:00
雨宮蓮
bba324ea53 refactor: use specta for commands (#192)
* feat: add tauri-specta

* refactor: system library

* chore: format
2024-05-25 15:21:40 +07:00
reya
7449000f5f chore: bump version 2024-05-23 08:01:04 +07:00
reya
dc7762ca11 fix: column lose state when navigate back 2024-05-23 08:00:33 +07:00
XIAO YU
3a3f960dde refactor: use borrowed references for adding and connecting relays (#191) 2024-05-23 07:08:39 +07:00
reya
12e066ff2e fix: web 2024-05-22 15:36:41 +07:00
reya
fe4f965ed5 chore: update website 2024-05-22 15:35:41 +07:00
reya
5d3f2264e9 chore: bump version 2024-05-22 13:26:08 +07:00
雨宮蓮
407fe40b67 refactor: account managements (#190)
* feat: add keyring-search

* feat: improve nostr connect
2024-05-22 13:24:58 +07:00
雨宮蓮
1f38eba2cc feat: immersive onboarding (#189)
* feat: change the default onboarding column

* feat: add newsfeed onboarding

* feat: add topic onboarding

* feat: add group onboarding

* chore: polish and format

* feat: rename foryou to topic

* fix: array
2024-05-22 10:44:19 +07:00
reya
9b5867f80c feat: improve tauri commands 2024-05-20 07:05:30 +07:00
reya
cac774a0c1 feat: small ui improve 2024-05-19 08:48:29 +07:00
reya
82689bf3c3 feat: add windows support for v4 2024-05-18 21:02:04 +07:00
reya
f60e438a64 feat: add custom titlebar 2024-05-18 19:07:34 +07:00
reya
ca06f2b6ed chore: clean up tauri commands 2024-05-18 14:59:34 +07:00
reya
99d9c70826 feat: improve event discovery 2024-05-18 08:16:05 +07:00
reya
60afbf090b chore: update to rust-nostr 0.31 2024-05-18 07:52:28 +07:00
reya
10ca4e6ff4 chore: bump version 2024-05-17 15:32:14 +07:00
reya
b0f387d029 fix: wrong permissions for dialog 2024-05-17 15:31:35 +07:00
reya
1a8f750640 fix: profile is not show on edit screen 2024-05-17 14:45:17 +07:00
reya
25523229a2 fix: copy to clipboard is not working properly 2024-05-16 14:24:55 +07:00
雨宮蓮
47835ed857 feat: improve ui for multi-account (#188) 2024-05-16 14:13:07 +07:00
reya
d84647bc6b chore: bump version 2024-05-15 13:58:39 +07:00
reya
7724eccd72 feat: improve tauri commands 2024-05-15 13:58:03 +07:00
reya
8ea2335225 chore: update deps 2024-05-15 10:50:35 +07:00
reya
b60d4db0df feat: improve multi-account switching 2024-05-15 10:14:21 +07:00
reya
f1e17ff3c4 fix: allow app auto restart after update 2024-05-13 15:36:47 +07:00
reya
32954f17b6 chore: bump version and update readme 2024-05-13 15:21:05 +07:00
雨宮蓮
cf70b0f882 fix: context issue in production (#187) 2024-05-13 15:18:23 +07:00
reya
135d0918b3 feat: add check for updates 2024-05-12 15:07:53 +07:00
reya
e1fbcf0460 chore: fix ci again 2024-05-12 08:57:57 +07:00
reya
99aaf3da82 fix: misconfigure in github actions 2024-05-12 08:54:24 +07:00
reya
3ef13e43f1 chore: update github ci 2024-05-12 08:38:23 +07:00
reya
8939196ae4 chore: bump version to 4.0 2024-05-12 08:20:52 +07:00
reya
571d4b4004 feat: improve search 2024-05-12 08:18:25 +07:00
reya
73f80f27fb feat: add basic relay management in rust 2024-05-11 12:28:07 +07:00
雨宮蓮
b46a5cf68f Merge pull request #186 from kasugamirai/chore
feat: Refactor code to improve error handling and readability
2024-05-10 07:13:32 +07:00
xy
8c0d03aed0 feat: Refactor code to improve error handling and readability 2024-05-09 19:09:55 +09:00
reya
777eb15b4f feat: onboarding 2024-05-09 15:06:42 +07:00
reya
c8e1b8b8bd feat: add unread notification badge to dock icon 2024-05-07 14:38:00 +07:00
reya
437cd71f7e feat: improve editor 2024-05-07 14:14:21 +07:00
reya
afb7c87fa3 feat: add bell 2024-05-07 08:29:58 +07:00
reya
c843626bca feat: add notification screen 2024-05-06 15:17:34 +07:00
reya
28337e5915 feat: improve dedup events 2024-05-04 08:29:52 +07:00
雨宮蓮
a4aef25adb final design (#184)
* feat: redesign

* feat: update other columns to new design

* chore: small fixes

* fix: better manage external webview

* feat: redesign note

* feat: update ui

* chore: update

* chore: update

* chore: polish ui

* chore: update auth ui

* feat: finalize note design

* chore: small fixes

* feat: add window management in rust

* chore: format

* feat: update ui for event screen

* feat: update event screen

* feat: final
2024-05-03 15:15:48 +07:00
reya
61d1f095d4 chore: clean up 2024-04-24 15:21:13 +07:00
雨宮蓮
f027eae52d feat: use negentropy (#182)
* feat: use negentropy

* chore: polish
2024-04-24 10:18:51 +07:00
雨宮蓮
174a3cc74e feat: add search window (NIP-50) (#181)
* feat: add search window
* chore: improve search ui
2024-04-23 15:34:08 +07:00
reya
c00a7749b4 feat: use nip44 to encrypt key-value store 2024-04-23 08:04:42 +07:00
Ren Amamiya
c755b8d137 feat: add ability change column name on the fly (#180)
Co-authored-by: reya <reya@lume.nu>
2024-04-22 14:33:14 +07:00
17766d29d6 chore: update deps 2024-04-22 13:18:34 +07:00
3b13dfeed8 chore: update github action 2024-04-20 18:13:36 +07:00
17ba79e01b chore: update app icon 2024-04-20 08:58:00 +07:00
bafad544e9 chore: update deps and bump version 2024-04-19 14:21:12 +07:00
89c36423ae feat: add nip46 2024-04-18 15:09:33 +07:00
cd31b99559 feat: improve official columns 2024-04-18 07:50:46 +07:00
f3c52237fa fix: privacy setting is not working 2024-04-17 13:50:50 +07:00
413d8d82df feat: finish settings screen 2024-04-16 15:28:38 +07:00
2eb2010d43 chore: upgrade deps 2024-04-16 08:55:36 +07:00
94d400cab2 feat: support nip-36 2024-04-16 07:49:44 +07:00
09b143cb08 feat: auto resize mini webview when main webview resized 2024-04-15 13:30:55 +07:00
e3ede34108 feat: revert to sqlite 2024-04-14 09:05:46 +07:00
ed6aca41ea feat: add new spinner component 2024-04-13 15:01:27 +07:00
89f577fbef feat: upgrade to rust nostr 0.30.0 and migrate to nostrdb 2024-04-13 08:53:31 +07:00
a14aeaeb55 feat: add empty state and polish trending column 2024-04-13 08:30:58 +07:00
reya
35cf0abda4 fix: support windows 2024-04-12 18:35:19 +07:00
8a7b246315 chore: update deps 2024-04-11 07:47:37 +07:00
Ren Amamiya
d98c6d0709 Merge pull request #175 from lumehq/v4/beta
Prepare for v4 - beta
2024-04-11 07:45:43 +07:00
bda20e8fe8 chore: small updates 2024-04-11 07:44:41 +07:00
c342daecc8 feat: improve 2024-04-10 14:11:05 +07:00
5e6692cd6d feat: re add group column 2024-04-09 14:05:50 +07:00
420be77b5c feat: add nstore 2024-04-07 15:11:20 +07:00
999073f84c feat: readd for you column 2024-04-04 13:47:15 +07:00
174b28f1a7 chore: update deps 2024-04-03 08:01:40 +07:00
763cb10e85 chore: clean up 2024-04-03 07:29:46 +07:00
89bb8d88f6 feat: settings screens 2024-04-02 13:19:26 +07:00
09aa2ecafc feat: re-add trending column 2024-03-30 10:50:45 +07:00
7271e9ea87 feat: add custom traffic light inset for macos 2024-03-29 13:26:35 +07:00
a02e496b29 chore: update tauri 2024-03-29 07:45:37 +07:00
cbbf5eaf50 feat: add create account flow 2024-03-28 15:12:43 +07:00
d3fa59d2b1 chore: polish 2024-03-27 08:36:34 +07:00
aa23e39334 feat: update new icons 2024-03-26 14:58:06 +07:00
c8e2018fd0 feat: update tray icon 2024-03-26 13:08:03 +07:00
6e489f1c49 wip: onboarding 2024-03-25 14:22:06 +07:00
a49b88ab35 Merge branch 'main' into v4/beta 2024-03-23 07:24:58 +07:00
31839531ea feat: improve open store column 2024-03-22 15:25:53 +07:00
ec0f3fabc0 feat: improve column management 2024-03-22 11:28:54 +07:00
dd7155a3a6 feat: update default column informations 2024-03-21 14:53:08 +07:00
cb565ff35b feat: readd default columns 2024-03-20 15:12:54 +07:00
5d59040224 chore: follow up 2024-03-19 15:36:20 +07:00
7fabf949c6 feat: move column manager to rust 2024-03-19 14:56:24 +07:00
ea5120e2f0 chore: upgrade tauri and bump version 2024-03-19 12:46:19 +07:00
14f07dfea8 feat: add lume store screen 2024-03-19 08:21:16 +07:00
Ren Amamiya
1de48cc640 Merge pull request #174 from fernandolguevara/minors/styling-scrollbar
feature: make scrollbar nicer
2024-03-19 07:10:28 +07:00
Fernando López Guevara
f72eb456e8 feature: make scrollbar nicer 2024-03-18 09:42:25 -03:00
05b52564e0 feat: column manager 2024-03-18 10:50:08 +07:00
c8e014f33e chore: clean up 2024-03-16 18:45:54 +07:00
46cc01e0ee feat: child webview 2024-03-16 15:05:06 +07:00
16e6d234e5 feat: space 2024-03-14 14:22:41 +07:00
3005d27403 feat: update fetch opg function 2024-03-13 10:24:45 +07:00
1be84f3139 chore: release v4.0.0-alpha+2 2024-03-13 09:40:30 +07:00
Ren Amamiya
04bde7dd43 Merge pull request #172 from lumehq/v4/functions
Some polish for v4
2024-03-13 09:39:57 +07:00
f1504d99ac feat: add mock tray menu 2024-03-13 09:19:25 +07:00
e928f2ee37 feat: polish 2024-03-13 08:40:18 +07:00
Ren Amamiya
ccab78ca11 Merge pull request #171 from lumehq/v4/zap
Add zap to v4
2024-03-11 15:39:35 +07:00
5b13c3e45c feat: auto close window if zapp successfully 2024-03-11 15:38:54 +07:00
e6b97ab9ae feat: add zap 2024-03-11 15:36:17 +07:00
bb6badfed6 feat: add zap setup screen 2024-03-10 16:39:23 +07:00
Ren Amamiya
0c4b309a11 Merge pull request #170 from lumehq/v4/home-screen
v4/home-screen
2024-03-09 10:09:29 +07:00
f4400d711f feat: improve home screen 2024-03-09 10:08:50 +07:00
a3e46aa96b fix: crash when database is not exist 2024-03-08 14:47:36 +07:00
a4fdcfdf0b feat: add local and global newsfeeds 2024-03-07 15:39:43 +07:00
25d07303a3 fix: build error in windows and linux 2024-03-06 15:32:51 +07:00
492 changed files with 28733 additions and 24998 deletions

View File

@@ -1,53 +1,56 @@
name: 'Publish'
name: "Publish"
on: workflow_dispatch
env:
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
RUSTFLAGS: '-W unreachable-pub -W rust-2021-compatibility'
RUSTFLAGS: "-W unreachable-pub -W rust-2021-compatibility"
jobs:
publish-tauri:
strategy:
fail-fast: false
matrix:
settings:
- platform: 'macos-latest'
args: '--target universal-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
- platform: 'windows-latest'
args: '--target x86_64-pc-windows-msvc'
runs-on: ${{ matrix.settings.platform }}
include:
- platform: "macos-latest" # for Arm based macs (M1 and above).
args: "--target aarch64-apple-darwin"
- platform: "macos-latest" # for Intel based macs.
args: "--target x86_64-apple-darwin"
- platform: "macos-latest" # for Intel based macs.
args: "--target universal-apple-darwin"
#- platform: 'ubuntu-22.04'
# args: ''
#- platform: 'windows-latest'
# args: '--target x86_64-pc-windows-msvc'
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v3
- name: setup node
uses: actions/setup-node@v3
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin
- name: install dependencies (ubuntu only)
if: matrix.settings.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
- name: Install pnpm
node-version: "lts/*"
- name: Install PNPM
uses: pnpm/action-setup@v2
with:
version: 8.x.x
run_install: false
- name: Setup node and cache for package data
uses: actions/setup-node@v3
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
node-version: 'lts/*'
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- run: pnpm install
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
- name: Install frontend dependencies
run: pnpm install
- uses: tauri-apps/tauri-action@dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -62,9 +65,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: v__VERSION__
releaseName: 'v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseName: "v__VERSION__"
releaseBody: "See the assets to download this version and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.settings.args }}
args: ${{ matrix.args }}
includeDebug: false

1
.gitignore vendored
View File

@@ -35,3 +35,4 @@ dist/
.DS_Store
*.pem
.vscode/
.idea/

View File

@@ -1,18 +1,14 @@
_Note_: Lume is under rewrite to using Rust Nostr as back-end and more lightweight front-end. If you need stable version, you can download v3 and below.
Source code for v3 is stored here: https://github.com/lumehq/lume/tree/old
--
## Introduction
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
## Usage
Download Lume v3 (v3.0.1-stable) for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
Download Lume v4 for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
Supported platform: macOS, Windows and Linux
Supported platform: macOS. Windows and Linux are coming soon.
Windows and Linux are availabel on v3 and below.
## Prerequisites

View File

@@ -6,7 +6,7 @@
<title>Lume Desktop</title>
</head>
<body
class="relative h-screen w-screen cursor-default select-none overflow-hidden bg-white font-sans text-black antialiased dark:bg-black dark:text-white"
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black antialiased dark:text-white"
>
<div id="root" class="h-full w-full"></div>
<script type="module" src="/src/app.tsx"></script>

View File

@@ -1,53 +1,61 @@
{
"name": "@lume/desktop2",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^",
"@lume/ui": "workspace:^",
"@lume/utils": "workspace:^",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-popover": "^1.0.7",
"@tanstack/query-sync-storage-persister": "^5.24.1",
"@tanstack/react-query": "^5.24.1",
"@tanstack/react-query-persist-client": "^5.24.1",
"@tanstack/react-router": "^1.18.1",
"i18next": "^23.10.0",
"i18next-resources-to-backend": "^1.2.0",
"nostr-tools": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^14.0.5",
"slate": "^0.101.5",
"slate-react": "^0.101.6",
"sonner": "^1.4.3",
"virtua": "^0.27.5"
},
"devDependencies": {
"@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@tanstack/router-devtools": "^1.18.1",
"@tanstack/router-vite-plugin": "^1.18.1",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react-swc": "^3.6.0",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.4",
"vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.1"
}
"name": "@lume/desktop2",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@getalby/bitcoin-connect-react": "^3.5.3",
"@lume/icons": "workspace:^",
"@lume/system": "workspace:^",
"@lume/ui": "workspace:^",
"@lume/utils": "workspace:^",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/query-persist-client-core": "^5.45.0",
"@tanstack/react-query": "^5.45.0",
"@tanstack/react-router": "^1.38.1",
"embla-carousel-react": "^8.1.5",
"i18next": "^23.11.5",
"i18next-resources-to-backend": "^1.2.1",
"minidenticons": "^4.2.1",
"nanoid": "^5.0.7",
"nostr-tools": "^2.7.0",
"react": "^18.3.1",
"react-currency-input-field": "^3.8.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.0",
"react-i18next": "^14.1.2",
"react-string-replace": "^1.1.1",
"slate": "^0.103.0",
"slate-react": "^0.105.0",
"use-debounce": "^10.0.1",
"virtua": "^0.31.0"
},
"devDependencies": {
"@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@tanstack/router-devtools": "^1.38.1",
"@tanstack/router-vite-plugin": "^1.38.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite": "^5.3.1",
"vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.2"
}
}

View File

@@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

View File

@@ -1,6 +1,32 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind components;
@layer utilities {
.content-break {
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
}
.shadow-toolbar {
box-shadow:
0 0 #0000,
0 0 #0000,
0 8px 24px 0 rgba(0, 0, 0, 0.2),
0 2px 8px 0 rgba(0, 0, 0, 0.08),
inset 0 0 0 1px rgba(0, 0, 0, 0.2),
inset 0 0 0 2px hsla(0, 0%, 100%, 0.14);
}
.shadow-primary {
box-shadow: 0px 0px 4px rgba(66, 65, 73, 0.14);
}
}
/*
Overide some default styles
*/
html {
font-size: 14px;
@@ -23,24 +49,62 @@ input::-ms-clear {
line-height: normal;
}
media-controller {
@apply w-full;
}
.spinner-leaf {
position: absolute;
top: 0;
left: calc(50% - 12.5% / 2);
width: 12.5%;
height: 100%;
animation: spinner-leaf-fade 800ms linear infinite;
@layer utilities {
.content-break {
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
&::before {
content: "";
display: block;
width: 100%;
height: 30%;
background-color: currentColor;
@apply rounded;
}
.shadow-toolbar {
box-shadow:
0 0 #0000,
0 0 #0000,
0 8px 24px 0 rgba(0, 0, 0, 0.2),
0 2px 8px 0 rgba(0, 0, 0, 0.08),
inset 0 0 0 1px rgba(0, 0, 0, 0.2),
inset 0 0 0 2px hsla(0, 0%, 100%, 0.14);
&:where(:nth-child(1)) {
transform: rotate(0deg);
animation-delay: -800ms;
}
&:where(:nth-child(2)) {
transform: rotate(45deg);
animation-delay: -700ms;
}
&:where(:nth-child(3)) {
transform: rotate(90deg);
animation-delay: -600ms;
}
&:where(:nth-child(4)) {
transform: rotate(135deg);
animation-delay: -500ms;
}
&:where(:nth-child(5)) {
transform: rotate(180deg);
animation-delay: -400ms;
}
&:where(:nth-child(6)) {
transform: rotate(225deg);
animation-delay: -300ms;
}
&:where(:nth-child(7)) {
transform: rotate(270deg);
animation-delay: -200ms;
}
&:where(:nth-child(8)) {
transform: rotate(315deg);
animation-delay: -100ms;
}
}
@keyframes spinner-leaf-fade {
from {
opacity: 1;
}
to {
opacity: 0.25;
}
}

View File

@@ -1,81 +1,53 @@
import { useArk } from "@lume/ark";
import { ArkProvider } from "./ark";
import { QueryClient } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import React, { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import "./app.css";
import { type } from "@tauri-apps/plugin-os";
import i18n from "./locale";
import { Toaster } from "sonner";
import { locale, platform } from "@tauri-apps/plugin-os";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { routeTree } from "./router.gen"; // auto generated file
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
});
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
const platformName = await platform();
const osLocale = (await locale()).slice(0, 2);
const queryClient = new QueryClient();
const os = await type();
// Set up a Router instance
const router = createRouter({
routeTree,
context: {
ark: undefined!,
platform: platformName,
locale: osLocale,
queryClient,
},
routeTree,
context: {
queryClient,
platform: os,
},
Wrap: ({ children }) => {
return (
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</I18nextProvider>
);
},
});
// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
function InnerApp() {
const ark = useArk();
return <RouterProvider router={router} context={{ ark }} />;
interface Register {
router: typeof router;
}
}
function App() {
return (
<ArkProvider>
<InnerApp />
</ArkProvider>
);
return <RouterProvider router={router} />;
}
// biome-ignore lint/style/noNonNullAssertion: idk
const rootElement = document.getElementById("root")!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
>
<StrictMode>
<Toaster position="top-center" richColors />
<App />
</StrictMode>
</PersistQueryClientProvider>
</I18nextProvider>,
);
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<App />
</StrictMode>,
);
}

View File

@@ -1,7 +0,0 @@
import { Ark, ArkContext } from "@lume/ark";
import { PropsWithChildren, useMemo } from "react";
export const ArkProvider = ({ children }: PropsWithChildren<object>) => {
const ark = useMemo(() => new Ark(), []);
return <ArkContext.Provider value={ark}>{children}</ArkContext.Provider>;
};

View File

@@ -1,149 +0,0 @@
import { useArk } from "@lume/ark";
import { Account } from "@lume/types";
import { User } from "@lume/ui";
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { BackupDialog } from "./backup";
import { LoginDialog } from "./login";
export function Accounts() {
const ark = useArk();
const params = useParams({ strict: false });
const [accounts, setAccounts] = useState<Account[]>(null);
useEffect(() => {
async function getAllAccounts() {
const data = await ark.get_all_accounts();
if (data) setAccounts(data);
}
getAllAccounts();
}, []);
return (
<div data-tauri-drag-region className="flex items-center gap-4">
{accounts
? accounts.map((account) =>
// @ts-ignore, useless
account.npub === params.account ? (
<Active key={account.npub} pubkey={account.npub} />
) : (
<Inactive key={account.npub} pubkey={account.npub} />
),
)
: null}
</div>
);
}
function Inactive({ pubkey }: { pubkey: string }) {
const ark = useArk();
const navigate = useNavigate();
const changeAccount = async (npub: string) => {
const select = await ark.load_selected_account(npub);
if (select) navigate({ to: "/$account/home", params: { account: npub } });
};
return (
<button type="button" onClick={() => changeAccount(pubkey)}>
<User.Provider pubkey={pubkey}>
<User.Root className="rounded-full">
<User.Avatar className="aspect-square h-auto w-8 rounded-full object-cover" />
</User.Root>
</User.Provider>
</button>
);
}
function Active({ pubkey }: { pubkey: string }) {
const [open, setOpen] = useState(true);
// @ts-ignore, magic !!!
const { guest } = useSearch({ strict: false });
if (guest) {
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button type="button">
<User.Provider pubkey={pubkey}>
<User.Root className="rounded-full ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950">
<User.Avatar className="aspect-square h-auto w-7 rounded-full object-cover" />
</User.Root>
</User.Provider>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="flex w-[280px] flex-col gap-4 rounded-xl bg-black p-5 text-neutral-100 focus:outline-none dark:bg-white dark:text-neutral-900 dark:shadow-none"
sideOffset={10}
side="bottom"
>
<div>
<h1 className="mb-1 font-semibold">
You're using random account
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-600">
You can continue by claim and backup this account, or you can
import your own account.
</p>
</div>
<div className="flex flex-col gap-2">
<BackupDialog />
<LoginDialog />
</div>
<Popover.Arrow className="fill-black dark:fill-white" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<User.Provider pubkey={pubkey}>
<User.Root className="rounded-full ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950">
<User.Avatar className="aspect-square h-auto w-7 rounded-full object-cover" />
</User.Root>
</User.Provider>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="flex w-[220px] flex-col rounded-xl bg-black p-2 text-neutral-100 focus:outline-none dark:bg-white dark:text-neutral-900 dark:shadow-none"
sideOffset={10}
side="bottom"
>
<DropdownMenu.Item className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100">
Add account
<div className="ml-auto pl-5 text-xs text-neutral-800 dark:text-neutral-200">
+Shift+N
</div>
</DropdownMenu.Item>
<DropdownMenu.Item className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100">
Profile
<div className="ml-auto pl-5 text-xs text-neutral-800 dark:text-neutral-200">
+Shift+P
</div>
</DropdownMenu.Item>
<DropdownMenu.Item className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100">
Settings
<div className="ml-auto pl-5 text-xs text-neutral-800 dark:text-neutral-200">
+Shift+S
</div>
</DropdownMenu.Item>
<DropdownMenu.Item className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100">
Logout
<div className="ml-auto pl-5 text-xs text-neutral-800 dark:text-neutral-200">
+Shift+L
</div>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@@ -0,0 +1,43 @@
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { message } from "@tauri-apps/plugin-dialog";
import {
type Dispatch,
type ReactNode,
type SetStateAction,
useState,
} from "react";
export function AvatarUploader({
setPicture,
children,
className,
}: {
setPicture: Dispatch<SetStateAction<string>>;
children: ReactNode;
className?: string;
}) {
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {
try {
setLoading(true);
const image = await NostrQuery.upload();
setPicture(image);
} catch (e) {
setLoading(false);
await message(String(e), { title: "Lume", kind: "error" });
}
};
return (
<button
type="button"
onClick={() => uploadAvatar()}
className={cn("size-4", className)}
>
{loading ? <Spinner className="size-4" /> : children}
</button>
);
}

View File

@@ -1,121 +0,0 @@
import { ArrowRightIcon, CancelIcon } from "@lume/icons";
import * as Dialog from "@radix-ui/react-dialog";
import { Link, useParams } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { toast } from "sonner";
export function BackupDialog() {
// @ts-ignore, magic!!!
const { account } = useParams({ strict: false });
const [key, setKey] = useState(null);
const [passphase, setPassphase] = useState("");
const [loading, setLoading] = useState(false);
const encryptKey = async () => {
try {
setLoading(true);
const encrypted: string = await invoke("get_encrypted_key", {
npub: account,
password: passphase,
});
if (encrypted) {
setKey(encrypted);
}
setLoading(false);
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-900 text-sm font-medium leading-tight text-neutral-100 hover:bg-neutral-800"
>
Claim & Backup
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/30 backdrop-blur dark:bg-white/30" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Dialog.Close className="absolute right-5 top-5 flex w-12 flex-col items-center justify-center gap-1 text-white">
<CancelIcon className="size-8" />
<span className="text-sm font-medium uppercase text-neutral-400 dark:text-neutral-600">
Esc
</span>
</Dialog.Close>
<div className="relative flex h-min w-full max-w-xl flex-col gap-8 rounded-xl bg-white p-5 dark:bg-black">
<div className="flex flex-col">
<h3 className="text-lg font-semibold">
This is your account key
</h3>
<p>
It's use for login to Lume or other Nostr clients. You will lost
access to your account if you lose this key.
</p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<label htmlFor="nsec">Set a passphase to secure your key</label>
<div className="relative">
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
</div>
</div>
{key ? (
<div className="flex flex-col gap-2">
<label htmlFor="nsec">
Copy this key and keep it in safe place
</label>
<input
name="nsec"
type="text"
value={key}
readOnly
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
</div>
) : null}
</div>
<div className="flex flex-col gap-3">
{!key ? (
<button
type="button"
onClick={encryptKey}
disabled={loading}
className="inline-flex h-11 w-full items-center justify-between gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
>
<div className="size-5" />
<div>Submit</div>
<ArrowRightIcon className="size-5" />
</button>
) : (
<Link
to="/$account/home"
params={{ account }}
search={{ guest: false }}
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
>
I've safely store my account key
</Link>
)}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,41 @@
import { User } from "@/components/user";
import { NostrAccount } from "@lume/system";
import { getBitcoinDisplayValues } from "@lume/utils";
import { useEffect, useMemo, useState } from "react";
export function Balance({ account }: { account: string }) {
const [balance, setBalance] = useState(0);
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
useEffect(() => {
async function getBalance() {
const val = await NostrAccount.getBalance();
setBalance(val);
}
getBalance();
}, []);
return (
<div
data-tauri-drag-region
className="flex h-16 items-center justify-end px-3"
>
<div className="flex items-center gap-2">
<div className="text-end">
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
Your balance
</div>
<div className="font-medium leading-tight">
{value.bitcoinFormatted}
</div>
</div>
<User.Provider pubkey={account}>
<User.Root>
<User.Avatar className="size-9 rounded-full" />
</User.Root>
</User.Provider>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { CancelIcon, CheckIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types";
import { cn } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { useCallback, useEffect, useRef, useState } from "react";
type WindowEvent = {
scroll: boolean;
resize: boolean;
};
export function Column({
column,
account,
}: {
column: LumeColumn;
account: string;
}) {
const container = useRef<HTMLDivElement>(null);
const webviewLabel = `column-${account}_${column.label}`;
const [isCreated, setIsCreated] = useState(false);
const repositionWebview = useCallback(async () => {
const newRect = container.current.getBoundingClientRect();
await invoke("reposition_column", {
label: webviewLabel,
x: newRect.x,
y: newRect.y,
});
}, []);
const resizeWebview = useCallback(async () => {
const newRect = container.current.getBoundingClientRect();
await invoke("resize_column", {
label: webviewLabel,
width: newRect.width,
height: newRect.height,
});
}, []);
useEffect(() => {
if (!isCreated) return;
const unlisten = listen<WindowEvent>("child-webview", (data) => {
if (data.payload.scroll) repositionWebview();
if (data.payload.resize) repositionWebview().then(() => resizeWebview());
});
return () => {
unlisten.then((f) => f());
};
}, [isCreated]);
useEffect(() => {
if (!container?.current) return;
const rect = container.current.getBoundingClientRect();
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
// create new webview
invoke("create_column", {
label: webviewLabel,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
url,
}).then(() => {
console.log("created: ", webviewLabel);
setIsCreated(true);
});
// close webview when unmounted
return () => {
invoke("close_column", { label: webviewLabel }).then(() => {
console.log("closed: ", webviewLabel);
});
};
}, [account]);
return (
<div className="h-full w-[500px] shrink-0 p-2">
<div
className={cn(
"flex flex-col w-full h-full rounded-xl",
column.label !== "open"
? "bg-black/5 dark:bg-white/5 backdrop-blur-sm"
: "",
)}
>
<Header label={column.label} name={column.name} />
<div ref={container} className="flex-1 w-full h-full" />
</div>
</div>
);
}
function Header({ label, name }: { label: string; name: string }) {
const [title, setTitle] = useState(name);
const [isChanged, setIsChanged] = useState(false);
const saveNewTitle = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "set_title", label, title });
// update search params
// @ts-ignore, hahaha
search.name = title;
// reset state
setIsChanged(false);
};
const close = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "remove", label });
};
useEffect(() => {
if (title.length !== name.length) setIsChanged(true);
}, [title]);
return (
<div className="flex items-center justify-between w-full px-1 h-9 shrink-0">
<div className="size-7" />
<div className="flex items-center justify-center shrink-0 h-9">
<div className="relative flex items-center gap-2">
<div
contentEditable
suppressContentEditableWarning={true}
onBlur={(e) => setTitle(e.currentTarget.textContent)}
className="text-sm font-medium focus:outline-none"
>
{name}
</div>
{isChanged ? (
<button
type="button"
onClick={() => saveNewTitle()}
className="text-teal-500 hover:text-teal-600"
>
<CheckIcon className="size-4" />
</button>
) : null}
</div>
</div>
<button
type="button"
onClick={() => close()}
className="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
>
<CancelIcon className="size-4" />
</button>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { ThreadIcon } from "@lume/icons";
import { Note } from "@/components/note";
import { cn } from "@lume/utils";
import type { LumeEvent } from "@lume/system";
import { useMemo } from "react";
export function Conversation({
event,
className,
}: {
event: LumeEvent;
className?: string;
}) {
const thread = useMemo(() => event.thread, [event]);
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div className="flex flex-col gap-3">
{thread?.root?.id ? <Note.Child event={thread?.root} isRoot /> : null}
<div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<ThreadIcon className="size-4" />
Thread
</div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div>
{thread?.reply?.id ? <Note.Child event={thread?.reply} /> : null}
<div>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
</div>
<Note.Content className="px-3" />
</div>
</div>
<div className="flex items-center px-3 h-14">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,120 +0,0 @@
import { useArk } from "@lume/ark";
import { ArrowRightIcon, CancelIcon } from "@lume/icons";
import * as Dialog from "@radix-ui/react-dialog";
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
export function LoginDialog() {
const ark = useArk();
const navigate = useNavigate();
const [nsec, setNsec] = useState("");
const [passphase, setPassphase] = useState("");
const [loading, setLoading] = useState(false);
const login = async () => {
try {
setLoading(true);
const save = await ark.save_account(nsec, passphase);
if (save) {
navigate({ to: "/", search: { guest: false } });
}
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-900 text-sm font-medium leading-tight text-neutral-100 hover:bg-neutral-800"
>
Add account
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/30 backdrop-blur dark:bg-white/30" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Dialog.Close className="absolute right-5 top-5 flex w-12 flex-col items-center justify-center gap-1 text-white">
<CancelIcon className="size-8" />
<span className="text-sm font-medium uppercase text-neutral-400 dark:text-neutral-600">
Esc
</span>
</Dialog.Close>
<div className="relative flex h-min w-full max-w-xl flex-col gap-8 rounded-xl bg-white p-5 dark:bg-black">
<div className="flex flex-col gap-1.5">
<h3 className="text-lg font-semibold">Add new account with</h3>
<div className="flex h-11 items-center overflow-hidden rounded-lg bg-neutral-100 p-1 dark:bg-neutral-900">
<button
type="button"
className="h-full flex-1 rounded-md bg-white text-sm font-medium dark:bg-black"
>
nsec
</button>
<button
type="button"
className="flex h-full flex-1 flex-col items-center justify-center rounded-md text-sm font-medium"
>
<span className="leading-tight">nsecBunker</span>
<span className="text-xs font-normal leading-tight text-neutral-700 dark:text-neutral-300">
coming soon
</span>
</button>
<button
type="button"
className="flex h-full flex-1 flex-col items-center justify-center rounded-md text-sm font-medium"
>
<span className="leading-tight">Address</span>
<span className="text-xs font-normal leading-tight text-neutral-700 dark:text-neutral-300">
coming soon
</span>
</button>
</div>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<label htmlFor="nsec">
Enter sign in key start with nsec or ncrypto
</label>
<input
name="nsec"
type="text"
value={nsec}
onChange={(e) => setNsec(e.target.value)}
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="nsec">Passphase (optional)</label>
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
</div>
</div>
<div className="flex flex-col gap-3">
<button
type="button"
onClick={login}
className="inline-flex h-11 w-full items-center justify-between gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
>
<div className="size-5" />
<div>Add account</div>
<ArrowRightIcon className="size-5" />
</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,11 +1,10 @@
import { PinIcon } from "@lume/icons";
import { VisitIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useTranslation } from "react-i18next";
import { useNoteContext } from "../provider";
import { LumeWindow } from "@lume/system";
export function NotePin() {
export function NoteOpenThread() {
const event = useNoteContext();
const { t } = useTranslation();
return (
<Tooltip.Provider>
@@ -13,15 +12,15 @@ export function NotePin() {
<Tooltip.Trigger asChild>
<button
type="button"
className="inline-flex items-center justify-center gap-2 pl-2 pr-3 text-sm font-medium rounded-full h-7 w-max bg-neutral-100 hover:bg-neutral-200 dark:hover:bg-neutral-800 dark:bg-neutral-900"
onClick={() => LumeWindow.openEvent(event)}
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
>
<PinIcon className="size-4" />
{t("note.buttons.pin")}
<VisitIcon className="shrink-0 size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm 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">
{t("note.buttons.pinTooltip")}
Open
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>

View File

@@ -0,0 +1,37 @@
import { ReplyIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useNoteContext } from "../provider";
import { cn } from "@lume/utils";
import { LumeWindow } from "@lume/system";
export function NoteReply({ large = false }: { large?: boolean }) {
const event = useNoteContext();
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => LumeWindow.openEditor(event.id)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
: "size-7",
)}
>
<ReplyIcon className="shrink-0 size-4" />
{large ? "Reply" : null}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 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 dark:bg-neutral-50 dark:text-neutral-950">
Reply
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,81 @@
import { RepostIcon } from "@lume/icons";
import { LumeWindow } from "@lume/system";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useState } from "react";
import { useNoteContext } from "../provider";
export function NoteRepost({ large = false }: { large?: boolean }) {
const event = useNoteContext();
const { settings } = useRouteContext({ strict: false });
const [loading, setLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const repost = async () => {
if (isRepost) return;
try {
setLoading(true);
// repost
await event.repost();
// update state
setLoading(false);
setIsRepost(true);
} catch {
setLoading(false);
await message("Repost failed, try again later", {
title: "Lume",
kind: "info",
});
}
};
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Quote",
action: async () => repost(),
}),
MenuItem.new({
text: "Repost",
action: () => LumeWindow.openEditor(null, event.id),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
if (!settings.display_repost_button) return null;
return (
<button
type="button"
onClick={(e) => showContextMenu(e)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full",
large
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
: "size-7",
)}
>
{loading ? (
<Spinner className="size-4" />
) : (
<RepostIcon className={cn("size-4", isRepost ? "text-blue-500" : "")} />
)}
{large ? "Repost" : null}
</button>
);
}

View File

@@ -0,0 +1,28 @@
import { ZapIcon } from "@lume/icons";
import { LumeWindow } from "@lume/system";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider";
export function NoteZap({ large = false }: { large?: boolean }) {
const event = useNoteContext();
const { settings } = useRouteContext({ strict: false });
if (!settings.display_zap_button) return null;
return (
<button
type="button"
onClick={() => LumeWindow.openZap(event.id, event.pubkey)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
: "size-7",
)}
>
<ZapIcon className="size-4" />
{large ? "Zap" : null}
</button>
);
}

View File

@@ -0,0 +1,48 @@
import { useEvent } from "@lume/system";
import { cn } from "@lume/utils";
import { Note } from ".";
import { InfoIcon } from "@lume/icons";
import type { EventTag } from "@lume/types";
export function NoteChild({
event,
isRoot,
}: {
event: EventTag;
isRoot?: boolean;
}) {
const { isLoading, isError, data } = useEvent(event.id, event.relayHint);
if (isLoading) {
return (
<div className="flex items-center gap-2 px-3 pt-3">
<div className="rounded-full size-8 shrink-0 bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
<div className="w-2/3 h-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-800" />
</div>
);
}
if (isError || !data) {
return (
<div className="flex items-center gap-2 px-3 pt-3">
<div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
<InfoIcon className="size-5" />
</div>
<p className="text-sm text-red-500">
Event not found with your current relay set
</p>
</div>
);
}
return (
<Note.Provider event={data}>
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
</div>
<Note.Content className="px-3" />
</Note.Root>
</Note.Provider>
);
}

View File

@@ -0,0 +1,141 @@
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { nanoid } from "nanoid";
import { type ReactNode, useMemo, useState } from "react";
import reactStringReplace from "react-string-replace";
import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user";
import { Images } from "./preview/images";
import { Videos } from "./preview/videos";
import { useNoteContext } from "./provider";
export function NoteContent({
quote = true,
mention = true,
clean,
className,
}: {
quote?: boolean;
mention?: boolean;
clean?: boolean;
className?: string;
}) {
const { settings } = useRouteContext({ strict: false });
const event = useNoteContext();
const warning = useMemo(() => event.warning, [event]);
const content = useMemo(() => {
try {
// Get parsed meta
const { content, hashtags, events, mentions } = event.meta;
// Define rich content
let richContent: ReactNode[] | string = settings.display_media
? content
: event.content;
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
richContent = reactStringReplace(richContent, regex, (_, index) => {
return <Hashtag key={hashtag + index} tag={hashtag} />;
});
}
for (const event of events) {
if (quote) {
richContent = reactStringReplace(richContent, event, (_, index) => (
<MentionNote key={event + index} eventId={event} />
));
}
if (!quote && clean) {
richContent = reactStringReplace(richContent, event, () => null);
}
}
for (const user of mentions) {
if (mention) {
richContent = reactStringReplace(richContent, user, (_, index) => (
<MentionUser key={user + index} pubkey={user} />
));
}
if (!mention && clean) {
richContent = reactStringReplace(richContent, user, () => null);
}
}
richContent = reactStringReplace(
richContent,
/(https?:\/\/\S+)/gi,
(match, index) => (
<a
key={match + index}
href={match}
target="_blank"
rel="noreferrer"
className="inline text-blue-500 hover:text-blue-600"
>
{match}
</a>
),
);
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
<div key={nanoid()} className="h-3" />
));
return richContent;
} catch {
return event.content;
}
}, [event.content]);
const [blurred, setBlurred] = useState(() => warning?.length > 0);
return (
<div className="relative flex flex-col gap-2">
{blurred ? (
<div className="absolute inset-0 z-10 flex items-center justify-center w-full h-full bg-black/80 backdrop-blur-xl">
<div className="flex flex-col items-center justify-center gap-2 text-center">
<p className="text-sm text-white/60">
The content is hidden because the author
<br />
marked it with a warning for a reason:
</p>
<p className="text-sm font-medium text-white">{warning}</p>
<button
type="button"
onClick={() => setBlurred(false)}
className="inline-flex items-center justify-center px-2 mt-4 text-sm font-medium border rounded-lg text-white/70 h-9 w-max bg-white/20 hover:bg-white/30 border-white/5"
>
View anyway
</button>
</div>
</div>
) : null}
<div
className={cn(
"select-text text-pretty content-break overflow-hidden",
event.meta?.content.length > 400
? "max-h-[250px] gradient-mask-b-0"
: "",
className,
)}
>
{content}
</div>
{settings.display_media ? (
<>
{event.meta?.images.length ? (
<Images urls={event.meta.images} />
) : null}
{event.meta?.videos.length ? (
<Videos urls={event.meta.videos} />
) : null}
</>
) : null}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { cn } from "@lume/utils";
import { nanoid } from "nanoid";
import { type ReactNode, useMemo } from "react";
import reactStringReplace from "react-string-replace";
import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user";
import { ImagePreview } from "./preview/image";
import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider";
export function NoteContentLarge({
className,
}: {
className?: string;
}) {
const event = useNoteContext();
const content = useMemo(() => {
try {
// Get parsed meta
const { images, videos, hashtags, events, mentions } = event.meta;
// Define rich content
let richContent: ReactNode[] | string = event.content;
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
richContent = reactStringReplace(richContent, regex, () => (
<Hashtag key={nanoid()} tag={hashtag} />
));
}
for (const event of events) {
richContent = reactStringReplace(richContent, event, (match, i) => (
<MentionNote key={match + i} eventId={event} />
));
}
for (const mention of mentions) {
richContent = reactStringReplace(richContent, mention, (match, i) => (
<MentionUser key={match + i} pubkey={mention} />
));
}
for (const image of images) {
richContent = reactStringReplace(richContent, image, (match, i) => (
<ImagePreview key={match + i} url={match} />
));
}
for (const video of videos) {
richContent = reactStringReplace(richContent, video, (match, i) => (
<VideoPreview key={match + i} url={match} />
));
}
richContent = reactStringReplace(
richContent,
/(https?:\/\/\S+)/gi,
(match, i) => (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="text-blue-500 line-clamp-1 hover:text-blue-600"
>
{match}
</a>
),
);
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
<div key={nanoid()} className="h-3" />
));
return richContent;
} catch (e) {
console.log("[parser]: ", e);
return event.content;
}
}, [event.content]);
return (
<div
className={cn(
"select-text leading-normal text-pretty content-break",
className,
)}
>
{content}
</div>
);
}

View File

@@ -1,14 +1,13 @@
import { NotePin } from "./buttons/pin";
import { NoteReaction } from "./buttons/reaction";
import { NoteOpenThread } from "./buttons/open";
import { NoteReply } from "./buttons/reply";
import { NoteRepost } from "./buttons/repost";
import { NoteZap } from "./buttons/zap";
import { NoteChild } from "./child";
import { NoteContent } from "./content";
import { NoteContentLarge } from "./contentLarge";
import { NoteMenu } from "./menu";
import { NoteProvider } from "./provider";
import { NoteRoot } from "./root";
import { NoteThread } from "./thread";
import { NoteUser } from "./user";
export const Note = {
@@ -18,10 +17,9 @@ export const Note = {
Menu: NoteMenu,
Reply: NoteReply,
Repost: NoteRepost,
Reaction: NoteReaction,
Content: NoteContent,
ContentLarge: NoteContentLarge,
Zap: NoteZap,
Pin: NotePin,
Open: NoteOpenThread,
Child: NoteChild,
Thread: NoteThread,
};

View File

@@ -0,0 +1,10 @@
export function Hashtag({ tag }: { tag: string }) {
return (
<span className="leading-normal break-all cursor-default group text-start">
<span className="text-blue-500">#</span>
<span className="underline underline-offset-1 decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
{tag.replace("#", "")}
</span>
</span>
);
}

View File

@@ -0,0 +1,76 @@
import { LumeWindow, useEvent } from "@lume/system";
import { LinkIcon } from "@lume/icons";
import { useTranslation } from "react-i18next";
import { cn } from "@lume/utils";
import { User } from "@/components/user";
import { Spinner } from "@lume/ui";
export function MentionNote({
eventId,
openable = true,
}: {
eventId: string;
openable?: boolean;
}) {
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="flex items-center justify-center w-full h-20 mt-2 border rounded-xl border-black/10 dark:border-white/10">
<Spinner className="size-5" />
</div>
);
}
if (isError || !data) {
return (
<div className="w-full p-3 mt-2 border rounded-xl border-black/10 dark:border-white/10">
{t("note.error")}
</div>
);
}
return (
<div className="flex flex-col w-full border rounded-lg cursor-default border-black/10 dark:border-white/10">
<User.Provider pubkey={data.pubkey}>
<User.Root className="flex items-center gap-2 px-3 h-11">
<User.Avatar className="object-cover rounded-full size-6 shrink-0" />
<div className="inline-flex items-center flex-1 gap-2">
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<User.Time
time={data.created_at}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
</User.Root>
</User.Provider>
<div
className={cn(
"px-3 select-text whitespace-normal text-pretty content-break leading-normal",
data.content.length > 400 ? "max-h-[150px] gradient-mask-b-0" : "",
)}
>
{data.content}
</div>
{openable ? (
<div className="flex items-center justify-end px-2 h-11">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
LumeWindow.openEvent(data);
}}
className="z-10 inline-flex items-center justify-center gap-1 text-sm rounded-full h-7 w-28 bg-black/10 dark:bg-white/10 text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
>
View post
<LinkIcon className="size-4" />
</button>
</div>
) : (
<div className="h-3" />
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { LumeWindow, useProfile } from "@lume/system";
import { displayNpub } from "@lume/utils";
export function MentionUser({ pubkey }: { pubkey: string }) {
const { isLoading, isError, profile } = useProfile(pubkey);
return (
<button
type="button"
onClick={() => LumeWindow.openProfile(pubkey)}
className="break-words text-start text-blue-500 hover:text-blue-600"
>
{isLoading
? "@anon"
: isError
? displayNpub(pubkey, 16)
: `@${profile?.name || profile?.display_name || "anon"}`}
</button>
);
}

View File

@@ -0,0 +1,62 @@
import { HorizontalDotsIcon } from "@lume/icons";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useNoteContext } from "./provider";
import { useCallback } from "react";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
export function NoteMenu() {
const event = useNoteContext();
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Copy Sharable Link",
action: async () => {
const eventId = await event.idAsBech32();
await writeText(`https://njump.me/${eventId}`);
},
}),
MenuItem.new({
text: "Copy Event ID",
action: async () => {
const eventId = await event.idAsBech32();
await writeText(eventId);
},
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Copy Raw Event",
action: async () => {
event.meta = undefined;
const raw = JSON.stringify(event);
await writeText(raw);
},
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
return (
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center justify-center group size-7 text-neutral-600 dark:text-neutral-400"
>
<HorizontalDotsIcon className="size-5" />
</button>
);
}

View File

@@ -0,0 +1,48 @@
import { useRouteContext } from "@tanstack/react-router";
import { open } from "@tauri-apps/plugin-shell";
import { useMemo } from "react";
export function ImagePreview({ url }: { url: string }) {
const { settings } = useRouteContext({ strict: false });
const imageUrl = useMemo(() => {
if (settings.image_resize_service.length) {
const newUrl = `${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`;
return newUrl;
} else {
return url;
}
}, [settings.image_resize_service]);
if (!settings.display_media) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
return (
<div className="my-1">
<img
src={imageUrl}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(url)}
onKeyDown={() => open(url)}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { open } from "@tauri-apps/plugin-shell";
import useEmblaCarousel from "embla-carousel-react";
import { useCallback, useEffect, useMemo, useState } from "react";
export function Images({ urls }: { urls: string[] }) {
const { settings } = useRouteContext({ strict: false });
const [slidesInView, setSlidesInView] = useState([]);
const [emblaRef, emblaApi] = useEmblaCarousel({
dragFree: true,
align: "start",
watchSlides: false,
});
const imageUrls = useMemo(() => {
if (settings.image_resize_service.length) {
let newUrls: string[];
if (urls.length === 1) {
newUrls = urls.map(
(url) =>
`${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`,
);
} else {
newUrls = urls.map(
(url) =>
`${settings.image_resize_service}?url=${url}&w=480&h=640&ll&af&default=1&n=-1`,
);
}
return newUrls;
} else {
return urls;
}
}, [settings.image_resize_service]);
const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev();
}, [emblaApi]);
const scrollNext = useCallback(() => {
if (emblaApi) emblaApi.scrollNext();
}, [emblaApi]);
const updateSlidesInView = useCallback((emblaApi) => {
setSlidesInView((slidesInView) => {
if (slidesInView.length === emblaApi.slideNodes().length) {
emblaApi.off("slidesInView", updateSlidesInView);
}
const inView = emblaApi
.slidesInView()
.filter((index) => !slidesInView.includes(index));
return slidesInView.concat(inView);
});
}, []);
useEffect(() => {
if (emblaApi && urls.length > 1) {
updateSlidesInView(emblaApi);
emblaApi.on("slidesInView", updateSlidesInView);
emblaApi.on("reInit", updateSlidesInView);
}
return () => {
emblaApi?.off("slidesInView", updateSlidesInView);
emblaApi?.off("reInit", updateSlidesInView);
};
}, [emblaApi, updateSlidesInView]);
if (urls.length === 1) {
return (
<div className="px-3 group">
<img
src={imageUrls[0]}
alt={urls[0]}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => urls[0]}
onKeyDown={() => urls[0]}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
</div>
);
}
return (
<div className="relative pl-2 overflow-hidden group">
<div ref={emblaRef} className="w-full">
<div className="flex w-full gap-2 scrollbar-none">
{imageUrls.map((url, index) => (
<LazyImage
/* biome-ignore lint/suspicious/noArrayIndexKey: url can be duplicated */
key={url + index}
url={url}
inView={slidesInView.indexOf(index) > -1}
/>
))}
</div>
</div>
<div
aria-hidden
className="absolute z-10 items-center justify-between hidden w-full px-5 transform -translate-x-1/2 -translate-y-1/2 group-hover:flex left-1/2 top-1/2"
>
<button
type="button"
disabled={!emblaApi?.canScrollPrev}
className={cn(
"size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
!emblaApi?.canScrollPrev ? "opacity-50" : "",
)}
onClick={() => scrollPrev()}
>
<ArrowLeftIcon className="size-6" />
</button>
<button
type="button"
disabled={!emblaApi?.canScrollNext}
className={cn(
"size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
!emblaApi?.canScrollNext ? "opacity-50" : "",
)}
onClick={() => scrollNext()}
>
<ArrowRightIcon className="size-6" />
</button>
</div>
</div>
);
}
function LazyImage({ url, inView }: { url: string; inView: boolean }) {
const [hasLoaded, setHasLoaded] = useState(false);
const setLoaded = useCallback(() => {
if (inView) setHasLoaded(true);
}, [inView, setHasLoaded]);
return (
<div className="w-[240px] h-[320px] shrink-0 relative rounded-lg overflow-hidden">
{!hasLoaded ? (
<div className="flex items-center justify-center size-full bg-black/5 dark:bg-white/5">
<Spinner className="size-4" />
</div>
) : null}
<img
src={
inView
? url
: "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
}
data-src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(url)}
onKeyDown={() => open(url)}
onLoad={setLoaded}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useRouteContext } from "@tanstack/react-router";
export function VideoPreview({ url }: { url: string }) {
const { settings } = useRouteContext({ strict: false });
if (settings.display_media) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
return (
<div className="my-1">
<video
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
controls
muted
>
<source src={url} type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Carousel, CarouselItem } from "@lume/ui";
export function Videos({ urls }: { urls: string[] }) {
if (urls.length === 1) {
return (
<div className="group px-3">
<video
className="w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
controls
muted
>
<source src={urls[0]} type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
);
}
return (
<Carousel
items={urls}
renderItem={({ item, isSnapPoint }) => (
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
<video
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
controls={false}
muted
>
<source src={item} type="video/mp4" />
Your browser does not support the video tag.
</video>
</CarouselItem>
)}
/>
);
}

View File

@@ -0,0 +1,22 @@
import type { LumeEvent } from "@lume/system";
import { type ReactNode, createContext, useContext } from "react";
const NoteContext = createContext<LumeEvent>(null);
export function NoteProvider({
event,
children,
}: {
event: LumeEvent;
children: ReactNode;
}) {
return <NoteContext.Provider value={event}>{children}</NoteContext.Provider>;
}
export function useNoteContext() {
const context = useContext(NoteContext);
if (!context) {
throw new Error("Please import Note Provider to use useNoteContext() hook");
}
return context;
}

View File

@@ -1,5 +1,5 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
import type { ReactNode } from "react";
export function NoteRoot({
children,
@@ -9,10 +9,7 @@ export function NoteRoot({
className?: string;
}) {
return (
<div
className={cn("h-min w-full overflow-hidden", className)}
contentEditable={false}
>
<div className={cn("h-min w-full", className)} contentEditable={false}>
{children}
</div>
);

View File

@@ -0,0 +1,62 @@
import { LumeWindow } from "@lume/system";
import { cn } from "@lume/utils";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useCallback } from "react";
import { User } from "../user";
import { useNoteContext } from "./provider";
export function NoteUser({ className }: { className?: string }) {
const event = useNoteContext();
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(event.pubkey),
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
return (
<User.Provider pubkey={event.pubkey}>
<User.Root className={cn("flex items-start justify-between", className)}>
<div className="flex w-full gap-2">
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="shrink-0"
>
<User.Avatar className="object-cover rounded-full size-8 outline outline-1 -outline-offset-1 outline-black/15" />
</button>
<div className="flex items-center w-full gap-3">
<div className="flex items-center gap-1">
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
<User.NIP05 />
</div>
<div className="text-neutral-600 dark:text-neutral-400">·</div>
<User.Time
time={event.created_at}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
</div>
</User.Root>
</User.Provider>
);
}

View File

@@ -0,0 +1,43 @@
import { QuoteIcon } from "@lume/icons";
import { Note } from "@/components/note";
import { cn } from "@lume/utils";
import type { LumeEvent } from "@lume/system";
export function Quote({
event,
className,
}: {
event: LumeEvent;
className?: string;
}) {
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div className="flex flex-col gap-3">
<Note.Child event={event.quote} isRoot />
<div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<QuoteIcon className="size-4" />
Quote
</div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div>
<div>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
</div>
<Note.Content className="px-3" quote={false} clean />
</div>
</div>
<div className="flex items-center px-3 h-14">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,113 +1,81 @@
import { RepostIcon } from "@lume/icons";
import { Event } from "@lume/types";
import { Spinner } from "@lume/ui";
import { Note } from "@/components/note";
import { User } from "@/components/user";
import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useArk } from "@lume/ark";
import { Note, User } from "@lume/ui";
import { type LumeEvent, NostrQuery } from "@lume/system";
export function RepostNote({
event,
className,
event,
className,
}: {
event: Event;
className?: string;
event: LumeEvent;
className?: string;
}) {
const ark = useArk();
const { isLoading, isError, data } = useQuery({
queryKey: ["event", event.repostId],
queryFn: async () => {
try {
const data = await NostrQuery.getRepostEvent(event);
return data;
} catch (e) {
throw new Error(e);
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
});
const { t } = useTranslation();
const {
isLoading,
isError,
data: repostEvent,
} = useQuery({
queryKey: ["repost", event.id],
queryFn: async () => {
try {
if (event.content.length > 50) {
const embed: Event = JSON.parse(event.content);
return embed;
}
const id = event.tags.find((el) => el[0] === "e")[1];
return await ark.get_event(id);
} catch {
throw new Error("Failed to get repost event");
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
});
if (isLoading) {
return <div className="w-full px-3 pb-3">Loading...</div>;
}
if (isError || !repostEvent) {
return (
<Note.Root className={className}>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex h-14 gap-2 px-3">
<div className="inline-flex w-10 shrink-0 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
<div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">{t("note.reposted")}</span>
</div>
</div>
</User.Root>
</User.Provider>
<div className="mb-3 select-text px-3">
<div className="flex flex-col items-start justify-start rounded-lg bg-red-100 px-3 py-3 dark:bg-red-900">
<p className="text-red-500">Failed to get event</p>
</div>
</div>
</Note.Root>
);
}
return (
<Note.Root
className={cn(
"mb-5 flex flex-col gap-2 border-b border-neutral-100 pb-5 dark:border-neutral-900",
className,
)}
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex gap-3">
<div className="inline-flex w-11 shrink-0 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
<div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">{t("note.reposted")}</span>
</div>
</div>
</User.Root>
</User.Provider>
<Note.Provider event={repostEvent}>
<div className="flex flex-col gap-2">
<Note.User />
<div className="flex gap-3">
<div className="size-11 shrink-0" />
<div className="min-w-0 flex-1">
<Note.Content />
<div className="mt-4 flex items-center justify-between">
<div className="-ml-1 inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
<Note.Menu />
</div>
</div>
</div>
</div>
</Note.Provider>
</Note.Root>
);
return (
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
{isLoading ? (
<div className="flex items-center justify-center h-20 gap-2">
<Spinner />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Loading event...
</span>
</div>
) : isError || !data ? (
<div className="flex items-center justify-center h-20">
Event not found within your current relay set
</div>
) : (
<Note.Provider event={data}>
<Note.Root>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" />
<div className="flex items-center justify-between px-3 mt-3 h-14">
<div className="inline-flex items-center gap-3">
<Note.Open />
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
<div>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex items-center gap-2">
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
Reposted by
</div>
<User.Avatar className="object-cover rounded-full size-6 shrink-0 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
</User.Root>
</User.Provider>
</div>
</div>
</Note.Root>
</Note.Provider>
)}
</Note.Root>
);
}

View File

@@ -1,56 +0,0 @@
import { LoaderIcon } from "@lume/icons";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { User } from "@lume/ui";
export function Suggest() {
const { t } = useTranslation();
const { isLoading, isError, data } = useQuery({
queryKey: ["trending-users"],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch("https://api.nostr.band/v0/trending/profiles", {
signal,
});
if (!res.ok) {
throw new Error("Failed to fetch trending users from nostr.band API.");
}
return res.json();
},
});
return (
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900">
<div className="h-10 shrink-0 text-lg font-semibold">
Suggested Follows
</div>
{isLoading ? (
<div className="flex h-44 w-full items-center justify-center">
<LoaderIcon className="size-4 animate-spin" />
</div>
) : isError ? (
<div className="flex h-44 w-full items-center justify-center">
{t("suggestion.error")}
</div>
) : (
data?.profiles.map((item: { pubkey: string }) => (
<div key={item.pubkey} className="h-max w-full overflow-hidden py-5">
<User.Provider pubkey={item.pubkey}>
<User.Root>
<div className="flex h-full w-full flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<User.Avatar className="size-10 shrink-0 rounded-full" />
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
</div>
<User.Button className="inline-flex h-8 w-20 items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800" />
</div>
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
</div>
</User.Root>
</User.Provider>
</div>
))
)}
</div>
);
}

View File

@@ -1,39 +1,34 @@
import { Event } from "@lume/types";
import { Note } from "@lume/ui";
import { cn } from "@lume/utils";
import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system";
export function TextNote({
event,
className,
event,
className,
}: {
event: Event;
className?: string;
event: LumeEvent;
className?: string;
}) {
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"mb-5 flex flex-col gap-2 border-b border-neutral-100 pb-5 dark:border-neutral-900",
className,
)}
>
<Note.User />
<div className="flex gap-3">
<div className="size-11 shrink-0" />
<div className="min-w-0 flex-1">
<Note.Content className="mb-2" />
<Note.Thread />
<div className="mt-4 flex items-center justify-between">
<div className="-ml-1 inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
<Note.Menu />
</div>
</div>
</div>
</Note.Root>
</Note.Provider>
);
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" />
<div className="flex items-center gap-4 px-3 mt-3 h-14">
<Note.Open />
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -0,0 +1,15 @@
import type { ReactNode } from "@tanstack/react-router";
import { useLayoutEffect, useState } from "react";
import { createPortal } from "react-dom";
export function Toolbar({ children }: { children: ReactNode }) {
const [domReady, setDomReady] = useState(false);
useLayoutEffect(() => {
setDomReady(true);
}, []);
return domReady
? createPortal(children, document.getElementById("toolbar"))
: null;
}

View File

@@ -0,0 +1,12 @@
import { cn } from "@lume/utils";
import { useUserContext } from "./provider";
export function UserAbout({ className }: { className?: string }) {
const user = useUserContext();
return (
<div className={cn("content-break select-text", className)}>
{user.profile?.about?.trim() || "No bio"}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { cn } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { useRouteContext } from "@tanstack/react-router";
import { minidenticon } from "minidenticons";
import { nanoid } from "nanoid";
import { useMemo } from "react";
import { useUserContext } from "./provider";
export function UserAvatar({ className }: { className?: string }) {
const user = useUserContext();
const { settings } = useRouteContext({ strict: false });
const picture = useMemo(() => {
if (
settings?.image_resize_service?.length &&
user.profile?.picture?.length
) {
const url = `${settings.image_resize_service}?url=${user.profile?.picture}&w=100&h=100&default=1&n=-1`;
return url;
} else {
return user.profile?.picture;
}
}, [user.profile?.picture]);
const fallbackAvatar = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user.pubkey || nanoid(), 90, 50),
)}`,
[user.pubkey],
);
if (settings && !settings.display_avatar) {
return (
<Avatar.Root className="shrink-0">
<Avatar.Fallback delayMs={120}>
<img
src={fallbackAvatar}
alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)}
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
return (
<Avatar.Root className="shrink-0">
<Avatar.Image
src={picture}
alt={user.pubkey}
loading="eager"
decoding="async"
className={cn("outline-[.5px] outline-black/5 object-cover", className)}
/>
<Avatar.Fallback delayMs={120}>
<img
src={fallbackAvatar}
alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)}
/>
</Avatar.Fallback>
</Avatar.Root>
);
}

View File

@@ -15,7 +15,7 @@ export function UserCover({ className }: { className?: string }) {
);
}
if (user && !user.profile.banner) {
if (user && !user.profile?.banner) {
return (
<div
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
@@ -25,7 +25,7 @@ export function UserCover({ className }: { className?: string }) {
return (
<img
src={user.profile.banner}
src={user?.profile?.banner}
alt="banner"
loading="lazy"
decoding="async"

View File

@@ -0,0 +1,60 @@
import { cn } from "@lume/utils";
import { useEffect, useState } from "react";
import { Spinner } from "@lume/ui";
import { useUserContext } from "./provider";
import { NostrAccount } from "@lume/system";
export function UserFollowButton({
simple = false,
className,
}: {
simple?: boolean;
className?: string;
}) {
const user = useUserContext();
const [loading, setLoading] = useState(false);
const [followed, setFollowed] = useState(false);
const toggleFollow = async () => {
setLoading(true);
const toggle = await NostrAccount.toggleContact(user.pubkey);
if (toggle) {
setFollowed((prev) => !prev);
setLoading(false);
}
};
useEffect(() => {
let mounted = true;
NostrAccount.checkContact(user.pubkey).then((status) => {
if (mounted) setFollowed(status);
});
return () => {
mounted = false;
};
}, []);
return (
<button
type="button"
disabled={loading}
onClick={() => toggleFollow()}
className={cn("w-max", className)}
>
{loading ? (
<Spinner className="size-4" />
) : followed ? (
!simple ? (
"Unfollow"
) : null
) : (
"Follow"
)}
</button>
);
}

View File

@@ -0,0 +1,21 @@
import { cn, displayNpub } from "@lume/utils";
import { useUserContext } from "./provider";
export function UserName({
className,
prefix,
}: {
className?: string;
prefix?: string;
}) {
const user = useUserContext();
return (
<div className={cn("max-w-[12rem] truncate", className)}>
{prefix}
{user.profile?.display_name ||
user.profile?.name ||
displayNpub(user.pubkey, 16)}
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { VerifiedIcon } from "@lume/icons";
import { displayLongHandle, displayNpub } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useQuery } from "@tanstack/react-query";
import { useUserContext } from "./provider";
import { NostrQuery } from "@lume/system";
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
export function UserNip05() {
const user = useUserContext();
const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.pubkey],
queryFn: async () => {
if (!user.profile?.nip05?.length) return false;
const verify = await NostrQuery.verifyNip05(
user.pubkey,
user.profile?.nip05,
);
return verify;
},
enabled: !!user.profile?.nip05,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: false,
persister: experimental_createPersister({
storage: localStorage,
maxAge: 1000 * 60 * 60 * 72, // 72 hours
}),
});
if (!user.profile?.nip05?.length) return;
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger>
{!isLoading && verified ? (
<VerifiedIcon className="text-teal-500 size-4" />
) : null}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm font-medium text-neutral-50 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 dark:bg-neutral-50 dark:text-neutral-950">
{!user.profile?.nip05
? displayNpub(user.pubkey, 16)
: user.profile?.nip05.length > 50
? displayLongHandle(user.profile?.nip05)
: user.profile.nip05?.replace("_@", "")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,33 @@
import { useProfile } from "@lume/system";
import type { Metadata } from "@lume/types";
import { type ReactNode, createContext, useContext } from "react";
const UserContext = createContext<{
pubkey: string;
profile: Metadata;
isError: boolean;
isLoading: boolean;
}>(null);
export function UserProvider({
pubkey,
children,
embedProfile,
}: {
pubkey: string;
children: ReactNode;
embedProfile?: string;
}) {
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
return (
<UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
{children}
</UserContext.Provider>
);
}
export function useUserContext() {
const context = useContext(UserContext);
return context;
}

View File

@@ -1,5 +1,5 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
import type { ReactNode } from "react";
export function UserRoot({
children,

View File

@@ -0,0 +1,18 @@
import { cn, formatCreatedAt } from "@lume/utils";
import { useMemo } from "react";
export function UserTime({
time,
className,
}: {
time: number;
className?: string;
}) {
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
return (
<div className={cn("text-neutral-600 dark:text-neutral-400", className)}>
{createdAt}
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { Column } from "@/components/column";
import { Toolbar } from "@/components/toolbar";
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { ColumnEvent, LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window";
import useEmblaCarousel from "embla-carousel-react";
import { nanoid } from "nanoid";
import { useCallback, useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
export const Route = createFileRoute("/$account/home")({
loader: async () => {
const columns = await NostrQuery.getColumns();
return columns;
},
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
const initialColumnList = Route.useLoaderData();
const [columns, setColumns] = useState<LumeColumn[]>([]);
const [emblaRef, emblaApi] = useEmblaCarousel({
watchDrag: false,
loop: false,
});
const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev(true);
}, [emblaApi]);
const scrollNext = useCallback(() => {
if (emblaApi) emblaApi.scrollNext(true);
}, [emblaApi]);
const emitScrollEvent = useCallback(() => {
getCurrent().emit("child-webview", { scroll: true });
}, []);
const emitResizeEvent = useCallback(() => {
getCurrent().emit("child-webview", { resize: true, direction: "x" });
}, []);
const openLumeStore = useDebouncedCallback(async () => {
await getCurrent().emit("columns", {
type: "add",
column: {
label: "store",
name: "Store",
content: "/store/official",
},
});
}, 150);
const add = useDebouncedCallback((column: LumeColumn) => {
column.label = `${column.label}-${nanoid()}`; // update col label
setColumns((prev) => [column, ...prev]);
}, 150);
const remove = useDebouncedCallback((label: string) => {
setColumns((prev) => prev.filter((t) => t.label !== label));
}, 150);
const updateName = useDebouncedCallback((label: string, title: string) => {
const currentColIndex = columns.findIndex((col) => col.label === label);
const updatedCol = Object.assign({}, columns[currentColIndex]);
updatedCol.name = title;
const newCols = columns.slice();
newCols[currentColIndex] = updatedCol;
setColumns(newCols);
}, 150);
const reset = useDebouncedCallback(() => setColumns([]), 150);
const handleKeyDown = useDebouncedCallback((event) => {
if (event.defaultPrevented) return;
switch (event.code) {
case "ArrowLeft":
if (emblaApi) emblaApi.scrollPrev(true);
break;
case "ArrowRight":
if (emblaApi) emblaApi.scrollNext(true);
break;
default:
break;
}
event.preventDefault();
}, 150);
useEffect(() => {
if (emblaApi) {
emblaApi.on("scroll", emitScrollEvent);
emblaApi.on("resize", emitResizeEvent);
emblaApi.on("slidesChanged", emitScrollEvent);
}
return () => {
emblaApi?.off("scroll", emitScrollEvent);
emblaApi?.off("resize", emitResizeEvent);
emblaApi?.off("slidesChanged", emitScrollEvent);
};
}, [emblaApi, emitScrollEvent, emitResizeEvent]);
useEffect(() => {
if (columns?.length) {
NostrQuery.setColumns(columns).then(() => console.log("saved"));
}
}, [columns]);
useEffect(() => {
setColumns(initialColumnList);
}, [initialColumnList]);
// Listen for keyboard event
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleKeyDown]);
// Listen for columns event
useEffect(() => {
const unlisten = listen<ColumnEvent>("columns", (data) => {
if (data.payload.type === "reset") reset();
if (data.payload.type === "add") add(data.payload.column);
if (data.payload.type === "remove") remove(data.payload.label);
if (data.payload.type === "set_title")
updateName(data.payload.label, data.payload.title);
});
return () => {
unlisten.then((f) => f());
};
}, []);
return (
<div className="size-full">
<div ref={emblaRef} className="overflow-hidden size-full">
<div className="flex size-full">
{columns?.map((column) => (
<Column
key={account + column.label}
column={column}
account={account}
/>
))}
</div>
</div>
<Toolbar>
<div className="flex items-center h-8 gap-1 p-[2px] rounded-full bg-black/5 dark:bg-white/5">
<button
type="button"
onClick={() => scrollPrev()}
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowLeftIcon className="size-4" />
</button>
<button
type="button"
onClick={() => openLumeStore()}
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<PlusSquareIcon className="size-4" />
</button>
<button
type="button"
onClick={() => scrollNext()}
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowRightIcon className="size-4" />
</button>
</div>
</Toolbar>
</div>
);
}

View File

@@ -1,130 +1,210 @@
import {
BellFilledIcon,
BellIcon,
ComposeFilledIcon,
HomeFilledIcon,
HomeIcon,
HorizontalDotsIcon,
SettingsIcon,
SpaceFilledIcon,
SpaceIcon,
} from "@lume/icons";
import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { User } from "@/components/user";
import { ComposeFilledIcon, HorizontalDotsIcon, PlusIcon } from "@lume/icons";
import { LumeWindow, NostrAccount } from "@lume/system";
import { cn } from "@lume/utils";
import { Accounts } from "@/components/accounts";
import { useArk } from "@lume/ark";
import { Box } from "@lume/ui";
import * as Popover from "@radix-ui/react-popover";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { getCurrent } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useMemo, useState } from "react";
export const Route = createFileRoute("/$account")({
component: App,
beforeLoad: async () => {
const accounts = await NostrAccount.getAccounts();
return { accounts };
},
component: Screen,
});
function App() {
const ark = useArk();
const context = Route.useRouteContext();
function Screen() {
const { platform } = Route.useRouteContext();
return (
<div className="flex h-screen w-screen flex-col bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900">
<div
data-tauri-drag-region
className={cn(
"flex h-11 shrink-0 items-center justify-between pr-4",
context.platform === "macos" ? "pl-24" : "pl-4",
)}
>
<Navigation />
<div className="flex items-center gap-3">
<Accounts />
<button
type="button"
onClick={() => ark.open_editor()}
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
>
<ComposeFilledIcon className="size-4" />
New post
</button>
<button
type="button"
className="inline-flex size-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-800 hover:bg-neutral-400 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-600"
>
<HorizontalDotsIcon className="size-5" />
</button>
</div>
</div>
<Box>
<Outlet />
</Box>
</div>
);
return (
<div className="flex flex-col w-screen h-screen">
<div
data-tauri-drag-region
className={cn(
"flex h-11 shrink-0 items-center justify-between pr-2",
platform === "macos" ? "ml-2 pl-20" : "pl-4",
)}
>
<div className="flex items-center gap-3">
<Accounts />
<Link
to="/landing"
className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
>
<PlusIcon className="size-5" />
</Link>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => LumeWindow.openEditor()}
className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium text-white bg-blue-500 rounded-full w-max hover:bg-blue-600"
>
<ComposeFilledIcon className="size-4" />
New Post
</button>
<div id="toolbar" />
</div>
</div>
<div className="flex-1">
<Outlet />
</div>
</div>
);
}
function Navigation() {
// @ts-ignore, useless
const { account } = Route.useParams();
function Accounts() {
const navigate = Route.useNavigate();
const { accounts } = Route.useRouteContext();
const { account } = Route.useParams();
return (
<div
data-tauri-drag-region
className="flex h-full flex-1 items-center gap-2"
>
<Link to="/$account/home" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
"inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3",
isActive
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-700"
: "hover:bg-black/10 dark:hover:bg-white/10",
)}
>
{isActive ? (
<HomeFilledIcon className="size-5" />
) : (
<HomeIcon className="size-5" />
)}
<span className="text-sm font-medium">Home</span>
</div>
)}
</Link>
<Link to="/$account/space" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
"inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3 hover:bg-black/10 dark:hover:bg-white/10",
isActive
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-700"
: "hover:bg-black/10 dark:hover:bg-white/10",
)}
>
{isActive ? (
<SpaceFilledIcon className="size-5" />
) : (
<SpaceIcon className="size-5" />
)}
<span className="text-sm font-medium">Space</span>
</div>
)}
</Link>
<Link to="/$account/activity" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
"inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3 hover:bg-black/10 dark:hover:bg-white/10",
isActive
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-700"
: "hover:bg-black/10 dark:hover:bg-white/10",
)}
>
{isActive ? (
<BellFilledIcon className="size-5" />
) : (
<BellIcon className="size-5" />
)}
<span className="text-sm font-medium">Activity</span>
</div>
)}
</Link>
</div>
);
const [windowWidth, setWindowWidth] = useState<number>(null);
const sortedList = useMemo(() => {
const list = accounts;
for (const [i, item] of list.entries()) {
if (item === account) {
list.splice(i, 1);
list.unshift(item);
}
}
return list;
}, [accounts]);
const showContextMenu = useCallback(
async (e: React.MouseEvent, npub: string) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(npub),
}),
MenuItem.new({
text: "Open Settings",
action: () => LumeWindow.openSettings(),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
},
[],
);
const changeAccount = async (e: React.MouseEvent, npub: string) => {
if (npub === account) {
return showContextMenu(e, npub);
}
// Change current account and update signer
const select = await NostrAccount.loadAccount(npub);
if (select) {
// Reset current columns
await getCurrent().emit("columns", { type: "reset" });
// Redirect to new account
return navigate({
to: "/$account/home",
params: { account: npub },
resetScroll: true,
replace: true,
});
} else {
await message("Something wrong.", { title: "Accounts", kind: "error" });
}
};
const getWindowDimensions = () => {
const { innerWidth: width, innerHeight: height } = window;
return {
width,
height,
};
};
useEffect(() => {
function handleResize() {
setWindowWidth(getWindowDimensions().width);
}
if (!windowWidth) {
setWindowWidth(getWindowDimensions().width);
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return (
<div data-tauri-drag-region className="flex items-center gap-3">
{sortedList
.slice(0, windowWidth > 500 ? account.length : 2)
.map((user) => (
<button
key={user}
type="button"
onClick={(e) => changeAccount(e, user)}
>
<User.Provider pubkey={user}>
<User.Root
className={cn(
"shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto",
user === account
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
: "",
)}
>
<User.Avatar
className={cn(
"aspect-square h-auto rounded-full object-cover transition-all ease-in-out duration-150 will-change-auto",
user === account ? "w-7" : "w-8",
)}
/>
</User.Root>
</User.Provider>
</button>
))}
{accounts.length >= 3 && windowWidth <= 700 ? (
<Popover.Root>
<Popover.Trigger className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
<HorizontalDotsIcon className="size-5" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content className="flex h-11 select-none items-center justify-center rounded-md bg-black/20 p-1 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">
{sortedList.slice(2).map((user) => (
<button
key={user}
type="button"
onClick={(e) => changeAccount(e, user)}
className="inline-flex items-center justify-center rounded-md size-9 hover:bg-white/10"
>
<User.Provider pubkey={user}>
<User.Root className="rounded-full ring-1 ring-white/10">
<User.Avatar className="object-cover h-auto rounded-full size-7 aspect-square" />
</User.Root>
</User.Provider>
</button>
))}
<Popover.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
) : null}
</div>
);
}

View File

@@ -1,13 +0,0 @@
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/$account/activity")({
component: Activity,
});
function Activity() {
return (
<div className="h-full w-full overflow-hidden rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
<p>Activity</p>
</div>
);
}

View File

@@ -1,133 +0,0 @@
import { RepostNote } from "@/components/repost";
import { Suggest } from "@/components/suggest";
import { TextNote } from "@/components/text";
import { useArk } from "@lume/ark";
import {
LoaderIcon,
ArrowRightCircleIcon,
RefreshIcon,
InfoIcon,
} from "@lume/icons";
import { Event, Kind } from "@lume/types";
import { EmptyFeed } from "@lume/ui";
import { FETCH_LIMIT } from "@lume/utils";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/$account/home")({
component: Home,
});
function Home() {
const ark = useArk();
const currentDate = new Date().toLocaleString("default", {
weekday: "long",
month: "long",
day: "numeric",
});
const { account } = Route.useParams();
const {
data,
hasNextPage,
isLoading,
isRefetching,
isFetchingNextPage,
fetchNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ["local_newsfeed", account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events(
"local",
FETCH_LIMIT,
pageParam,
true,
);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<div className="flex flex-col gap-3">
<div className="mx-auto flex h-12 w-full max-w-xl shrink-0 items-center justify-between border-b border-neutral-100 dark:border-neutral-900">
<h3 className="text-sm font-medium uppercase leading-tight text-neutral-600 dark:text-neutral-400">
{currentDate}
</h3>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => refetch()}
className="text-neutral-700 hover:text-blue-500 dark:text-neutral-300"
>
<RefreshIcon className="size-4" />
</button>
</div>
</div>
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col">
<div className="flex-1">
{isLoading || isRefetching ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<LoaderIcon className="size-5 animate-spin" />
</div>
) : !data.length ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<InfoIcon className="size-5" />
<p>
Empty newsfeed. Or you can go to{" "}
<a href="" className="text-blue-500 hover:text-blue-600">
Discover
</a>
</p>
</div>
<Suggest />
</div>
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
<div className="flex h-20 items-center justify-center">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isFetchingNextPage ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
) : null}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,13 +0,0 @@
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/$account/space")({
component: Space,
});
function Space() {
return (
<div className="h-full w-full overflow-hidden rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
<p>Space</p>
</div>
);
}

View File

@@ -1,35 +1,31 @@
import { LoaderIcon } from "@lume/icons";
import {
Outlet,
ScrollRestoration,
createRootRouteWithContext,
} from "@tanstack/react-router";
import { type Ark } from "@lume/ark";
import { type QueryClient } from "@tanstack/react-query";
import { type Platform } from "@tauri-apps/plugin-os";
import type { Settings } from "@lume/system";
import { Spinner } from "@lume/ui";
import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import type { Platform } from "@tauri-apps/plugin-os";
interface RouterContext {
ark: Ark;
queryClient: QueryClient;
platform: Platform;
locale: string;
// System
queryClient: QueryClient;
// App info
platform?: Platform;
locale?: string;
// Settings
settings?: Settings;
// Accounts
accounts?: string[];
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => (
<>
<ScrollRestoration />
<Outlet />
</>
),
pendingComponent: Pending,
wrapInSuspense: true,
component: () => <Outlet />,
pendingComponent: Pending,
wrapInSuspense: true,
});
function Pending() {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center">
<LoaderIcon className="size-5 animate-spin" />
</div>
);
return (
<div className="flex flex-col items-center justify-center w-screen h-screen">
<Spinner className="size-5" />
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Box, Container } from "@lume/ui";
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/auth")({
component: Screen,
});
function Screen() {
return (
<Container withDrag>
<Box className="px-3 pt-3">
<Outlet />
</Box>
</Container>
);
}

View File

@@ -0,0 +1,200 @@
import { CheckIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import { displayNsec } from "@lume/utils";
import * as Checkbox from "@radix-ui/react-checkbox";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/auth/$account/backup")({
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
const { t } = useTranslation();
const [key, setKey] = useState(null);
const [passphase, setPassphase] = useState("");
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false);
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
const navigate = useNavigate();
const submit = async () => {
try {
if (key) {
if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
return await message("You need to confirm before continue", {
title: "Backup",
kind: "info",
});
}
navigate({ to: "/", replace: true });
}
// start loading
setLoading(true);
invoke("get_encrypted_key", {
npub: account,
password: passphase,
}).then((encrypted: string) => {
// update state
setKey(encrypted);
setLoading(false);
});
} catch (e) {
setLoading(false);
await message(String(e), {
title: "Backup",
kind: "error",
});
}
};
const copyKey = async () => {
try {
await writeText(key);
setCopied(true);
} catch (e) {
await message(String(e), {
title: "Backup",
kind: "error",
});
}
};
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="flex flex-col text-center">
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
<p className="text-neutral-700 dark:text-neutral-300">
It's use for login to Lume or other Nostr clients. You will lost
access to your account if you lose this key.
</p>
</div>
<div className="flex flex-col w-full gap-5">
<div className="flex flex-col gap-2">
<label htmlFor="passphase" className="font-medium">
Set a passphase to secure your key
</label>
<div className="relative">
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
</div>
{key ? (
<>
<div className="flex flex-col gap-2">
<label htmlFor="nsec" className="font-medium">
Copy this key and keep it in safe place
</label>
<div className="flex items-center gap-2">
<input
name="nsec"
type="text"
value={displayNsec(key, 36)}
readOnly
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => copyKey()}
className="inline-flex items-center justify-center w-24 rounded-lg h-11 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="font-medium">Before you continue:</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c1}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c1: !state.c1 }))
}
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm1"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm1"
>
{t("backup.confirm1")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c2}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c2: !state.c2 }))
}
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm2"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm2"
>
{t("backup.confirm2")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c3}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c3: !state.c3 }))
}
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm3"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm3"
>
{t("backup.confirm3")}
</label>
</div>
</div>
</div>
</>
) : null}
<div>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="inline-flex items-center justify-center w-full font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : t("global.continue")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/auth/create-profile")({
component: Screen,
loader: async () => {
const account = await NostrAccount.createAccount();
return account;
},
});
function Screen() {
const account = Route.useLoaderData();
const navigate = useNavigate();
const { t } = useTranslation();
const { register, handleSubmit } = useForm();
const [picture, setPicture] = useState<string>("");
const [loading, setLoading] = useState(false);
const onSubmit = async (data: {
name: string;
about: string;
website: string;
}) => {
setLoading(true);
try {
// Save account keys
const save = await NostrAccount.saveAccount(account.nsec);
// Then create profile
if (save) {
const profile: Metadata = { ...data, picture };
const eventId = await NostrAccount.createProfile(profile);
if (eventId) {
navigate({
to: "/auth/$account/backup",
params: { account: account.npub },
replace: true,
});
}
}
} catch (e) {
setLoading(false);
await message(String(e), { title: "Create Profile", kind: "error" });
}
};
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="text-center">
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
</div>
<div>
<div className="relative rounded-full size-24 bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{picture ? (
<img
src={picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
/>
) : null}
<AvatarUploader
setPicture={setPicture}
className="absolute inset-0 z-20 flex items-center justify-center w-full h-full text-white rounded-full dark:text-black bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<PlusIcon className="size-8" />
</AvatarUploader>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col w-full gap-3"
>
<div className="flex flex-col gap-1">
<label htmlFor="display_name" className="font-medium">
{t("user.displayName")} *
</label>
<input
type={"text"}
{...register("display_name", { required: true, minLength: 1 })}
placeholder="e.g. Alice in Nostrland"
spellCheck={false}
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="name" className="font-medium">
{t("user.name")}
</label>
<input
type={"text"}
{...register("name")}
placeholder="e.g. alice"
spellCheck={false}
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="about" className="font-medium">
{t("user.bio")}
</label>
<textarea
{...register("about")}
placeholder="e.g. Artist, anime-lover, and k-pop fan"
spellCheck={false}
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="website" className="font-medium">
{t("user.website")}
</label>
<input
type="url"
{...register("website")}
placeholder="e.g. https://alice.me"
spellCheck={false}
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<button
type="submit"
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : t("global.continue")}
</button>
</form>
</div>
);
}

View File

@@ -1,108 +0,0 @@
import { LoaderIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export const Route = createLazyFileRoute("/auth/create/")({
component: Screen,
});
function Screen() {
const navigate = useNavigate();
const [t] = useTranslation();
const [method, setMethod] = useState<"self" | "managed">("self");
const [loading, setLoading] = useState(false);
const next = () => {
setLoading(true);
if (method === "self") {
navigate({ to: "/auth/create/self" });
} else {
navigate({ to: "/auth/create/managed" });
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-semibold">{t("signup.title")}</h1>
<p className="text-lg leading-snug text-neutral-600 dark:text-neutral-500">
{t("signup.subtitle")}
</p>
</div>
<div className="flex flex-col gap-4">
<button
type="button"
onClick={() => setMethod("self")}
className={cn(
"flex flex-col items-start rounded-xl bg-neutral-100 px-4 py-3.5 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800",
method === "self"
? "ring-1 ring-blue-500 ring-offset-2 ring-offset-white dark:ring-offset-black"
: "",
)}
>
<p className="font-semibold">{t("signup.selfManageMethod")}</p>
<p className="text-sm text-neutral-600 dark:text-neutral-500">
{t("signup.selfManageMethodDescription")}
</p>
</button>
<button
type="button"
onClick={() => setMethod("managed")}
className={cn(
"flex flex-col items-start rounded-xl bg-neutral-100 px-4 py-3.5 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800",
method === "managed"
? "ring-1 ring-blue-500 ring-offset-2 ring-offset-white dark:ring-offset-black"
: "",
)}
>
<p className="font-semibold">{t("signup.providerMethod")}</p>
<p className="text-sm text-neutral-600 dark:text-neutral-500">
{t("signup.providerMethodDescription")}
</p>
</button>
<div className="flex flex-col gap-3">
<button
type="button"
onClick={next}
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-blue-500 text-lg font-medium text-white hover:bg-blue-600"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
t("global.continue")
)}
</button>
{method === "managed" ? (
<div className="flex flex-col gap-1.5 rounded-xl border border-red-200 bg-red-100 p-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-900 dark:text-red-200">
<p className="font-semibold text-red-900 dark:text-red-100">
Attention:
</p>
<p>
You're chosing Managed by Provider, this feature still in
"Beta".
</p>
<p>
Some functions still missing or not work as expected, you
shouldn't create your main account with this method
</p>
<a
href="https://github.com/kind-0/nsecbunkerd/blob/master/OAUTH-LIKE-FLOW.md"
target="_blank"
rel="noreferrer"
className="text-blue-500"
>
Learn more
</a>
</div>
) : null}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +0,0 @@
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/auth/create/managed')({
component: () => <div>Hello /auth/create/managed!</div>
})

View File

@@ -1,161 +0,0 @@
import { useArk } from "@lume/ark";
import { CheckIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from "@lume/icons";
import { Keys } from "@lume/types";
import * as Checkbox from "@radix-ui/react-checkbox";
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/create/self")({
component: Create,
});
function Create() {
const ark = useArk();
const navigate = useNavigate();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [showKey, setShowKey] = useState(false);
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
const [keys, setKeys] = useState<Keys>(null);
const submit = async () => {
setLoading(true);
try {
await ark.save_account(keys);
navigate({
to: "/$account/home",
params: { account: keys.npub },
search: { onboarding: true },
replace: true,
});
} catch (e) {
setLoading(false);
toast.error(e);
}
};
useEffect(() => {
async function genKeys() {
const res = await ark.create_keys();
setKeys(res);
}
genKeys();
}, []);
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-semibold">
{t("signupWithSelfManage.title")}
</h1>
<p className="text-lg leading-snug text-neutral-600 dark:text-neutral-500">
{t("signupWithSelfManage.subtitle")}
</p>
</div>
<div className="mb-0 flex flex-col gap-6">
<div className="flex flex-col gap-4">
<div className="relative">
{keys ? (
<input
readOnly
value={keys.nsec}
type={showKey ? "text" : "password"}
className="h-12 w-full resize-none rounded-xl border-transparent bg-neutral-100 pl-3 pr-14 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
) : null}
<button
type="button"
onClick={() => setShowKey((state) => !state)}
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
{showKey ? (
<EyeOnIcon className="size-4" />
) : (
<EyeOffIcon className="size-4" />
)}
</button>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c1}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c1: !state.c1 }))
}
className="flex size-7 appearance-none items-center justify-center rounded-lg bg-neutral-100 outline-none dark:bg-neutral-900"
id="confirm1"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm text-neutral-700 dark:text-neutral-400"
htmlFor="confirm1"
>
{t("signupWithSelfManage.confirm1")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c3}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c3: !state.c3 }))
}
className="flex size-7 appearance-none items-center justify-center rounded-lg bg-neutral-100 outline-none dark:bg-neutral-900"
id="confirm3"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm text-neutral-700 dark:text-neutral-400"
htmlFor="confirm3"
>
{t("signupWithSelfManage.confirm3")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c2}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c2: !state.c2 }))
}
className="flex size-7 appearance-none items-center justify-center rounded-lg bg-neutral-100 outline-none dark:bg-neutral-900"
id="confirm2"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm text-neutral-700 dark:text-neutral-400"
htmlFor="confirm2"
>
{t("signupWithSelfManage.confirm2")}
</label>
</div>
</div>
</div>
<button
type="button"
onClick={submit}
disabled={!confirm.c1 || !confirm.c2 || !confirm.c3}
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-blue-500 text-lg font-medium text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
t("signupWithSelfManage.button")
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,101 +1,88 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/import")({
component: Import,
component: Screen,
});
function Import() {
const ark = useArk();
const navigate = useNavigate();
function Screen() {
const navigate = Route.useNavigate();
const [t] = useTranslation();
const [key, setKey] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [key, setKey] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!key.startsWith("nsec1")) return;
if (key.length < 30) return;
const submit = async () => {
if (!key.startsWith("nsec1")) {
return await message(
"You need to enter a valid private key starts with nsec or ncryptsec",
{ title: "Import Key", kind: "info" },
);
}
setLoading(true);
try {
setLoading(true);
try {
const npub: string = await invoke("get_public_key", { nsec: key });
await ark.save_account({
npub,
nsec: key,
});
navigate({
to: "/$account/home",
params: { account: npub },
search: { onboarding: true },
replace: true,
});
} catch (e) {
setLoading(false);
toast.error(e);
}
};
const npub = await NostrAccount.saveAccount(key, password);
const isNip05 = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/.test(key);
const isNip49 = key.startsWith("ncryptsec");
if (npub) {
navigate({ to: "/", replace: true });
}
} catch (e) {
setLoading(false);
await message(String(e), { title: "Import Key", kind: "error" });
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-semibold">{t("login.title")}</h1>
<p className="text-lg leading-snug text-neutral-600 dark:text-neutral-500">
{t("login.subtitle")}
</p>
</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<div>
<input
value={key}
type="text"
onChange={(e) => setKey(e.target.value)}
className="h-12 w-full resize-none rounded-xl border-transparent bg-neutral-100 pl-3 pr-10 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
</div>
{isNip05 || isNip49 ? (
<div className="flex flex-col gap-1.5">
<label
htmlFor="password"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Password *
</label>
<input
value={password}
name="password"
type="password"
onChange={(e) => setPassword(e.target.value)}
className="h-12 w-full resize-none rounded-xl border-transparent bg-neutral-100 pl-3 pr-10 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
</div>
) : null}
</div>
<button
type="button"
onClick={submit}
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-blue-500 text-lg font-medium text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
"Import"
)}
</button>
</div>
</div>
</div>
);
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="text-center">
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
</div>
<div className="flex flex-col w-full gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="key"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Private Key
</label>
<input
name="key"
type="text"
placeholder="nsec or ncryptsec..."
value={key}
onChange={(e) => setKey(e.target.value)}
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="password"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Password (Optional)
</label>
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : "Login"}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
export const Route = createLazyFileRoute("/auth/remote")({
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const [uri, setUri] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!uri.startsWith("bunker://")) {
return await message(
"You need to enter a valid Connect URI starts with bunker://",
{ title: "Nostr Connect", kind: "info" },
);
}
try {
setLoading(true);
const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
if (remoteAccount?.length) {
navigate({ to: "/", replace: true });
}
} catch (e) {
setLoading(false);
await message(String(e), { title: "Nostr Connect", kind: "error" });
}
};
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="text-center">
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
</div>
<div className="flex flex-col w-full gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="uri"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Connect URI
</label>
<input
name="uri"
type="text"
placeholder="bunker://..."
value={uri}
onChange={(e) => setUri(e.target.value)}
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col items-center gap-1">
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : "Login"}
</button>
{loading ? (
<p className="text-sm text-center text-neutral-600 dark:text-neutral-400">
Waiting confirmation...
</p>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { CancelIcon, PlusIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { Relay } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
export const Route = createFileRoute("/bootstrap-relays")({
loader: async () => {
const bootstrapRelays = await NostrQuery.getBootstrapRelays();
return bootstrapRelays;
},
component: Screen,
});
function Screen() {
const bootstrapRelays = Route.useLoaderData();
const { register, reset, handleSubmit } = useForm();
const [relays, setRelays] = useState<Relay[]>([]);
const [isLoading, setIsLoading] = useState(false);
const removeRelay = (url: string) => {
setRelays((prev) => prev.filter((relay) => relay.url !== url));
};
const onSubmit = async (data: { url: string; purpose: string }) => {
try {
const relay: Relay = { url: data.url, purpose: data.purpose };
setRelays((prev) => [...prev, relay]);
reset();
} catch (e) {
await message(String(e), { title: "Bootstrap Relays", kind: "error" });
}
};
const save = async () => {
try {
setIsLoading(true);
await NostrQuery.saveBootstrapRelays(relays);
} catch (e) {
await message(String(e), { title: "Bootstrap Relays", kind: "error" });
}
};
useEffect(() => {
setRelays(bootstrapRelays);
}, [bootstrapRelays]);
return (
<div className="flex flex-col items-center justify-center w-screen h-screen">
<div className="w-full max-w-sm mx-auto lg:max-w-lg">
<div className="text-center h-11">
<h1 className="font-semibold">Customize Bootstrap Relays</h1>
</div>
<div className="flex flex-col w-full px-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
{relays.map((relay) => (
<div
key={relay.url}
className="flex items-center justify-between h-11"
>
<div className="inline-flex items-center gap-2 text-sm font-medium">
{relay.url}
</div>
<div className="flex items-center gap-2">
{relay.purpose?.length ? (
<button
type="button"
className="inline-flex items-center justify-center px-2 text-xs font-medium uppercase rounded-md h-7 w-max hover:bg-black/10 dark:hover:bg-white/10"
>
{relay.purpose}
</button>
) : null}
<button
type="button"
onClick={() => removeRelay(relay.url)}
className="inline-flex items-center justify-center rounded-md size-7 text-neutral-700 dark:text-white/20 hover:bg-black/10 dark:hover:bg-white/10"
>
<CancelIcon className="size-3" />
</button>
</div>
</div>
))}
<div className="flex items-center border-t h-14 border-neutral-100 dark:border-white/5">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex items-center w-full gap-2 mb-0"
>
<div className="flex items-center flex-1 gap-2 border rounded-lg border-neutral-300 dark:border-white/20">
<input
{...register("url", {
required: true,
minLength: 1,
})}
name="url"
placeholder="wss://..."
spellCheck={false}
className="flex-1 px-3 bg-transparent border-none rounded-l-lg h-9 placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
/>
<select
{...register("purpose")}
className="flex-1 p-0 m-0 text-sm bg-transparent border-none outline-none h-9 ring-0 focus:outline-none focus:ring-0"
>
<option value="read">Read</option>
<option value="write">Write</option>
<option value="">Both</option>
</select>
</div>
<button
type="submit"
className="inline-flex items-center justify-center px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 w-14 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
>
<PlusIcon className="size-7" />
</button>
</form>
</div>
</div>
<button
type="button"
onClick={() => save()}
disabled={isLoading}
className="inline-flex items-center justify-center w-full h-10 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Save & Relaunch"}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,198 @@
import { User } from "@/components/user";
import { CancelIcon, PlusIcon } from "@lume/icons";
import { NostrAccount, NostrQuery } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
export const Route = createFileRoute("/create-group")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
loader: async () => {
const contacts = await NostrAccount.getContactList();
return contacts;
},
component: Screen,
});
function Screen() {
const [title, setTitle] = useState("");
const [npub, setNpub] = useState("");
const [users, setUsers] = useState<string[]>([
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445", // reya
]);
const [isLoading, setIsLoading] = useState(false);
const contacts = Route.useLoaderData();
const search = Route.useSearch();
const navigate = Route.useNavigate();
const toggleUser = (pubkey: string) => {
setUsers((prev) =>
prev.includes(pubkey)
? prev.filter((i) => i !== pubkey)
: [...prev, pubkey],
);
};
const addUser = () => {
if (!npub.startsWith("npub1")) return;
if (users.includes(npub)) return;
setUsers((prev) => [...prev, npub]);
setNpub("");
};
const submit = async () => {
try {
setIsLoading(true);
const key = `lume_group_${search.label}`;
const createGroup = await NostrQuery.setNstore(
key,
JSON.stringify(users),
);
if (createGroup) {
return navigate({ to: search.redirect, search: { ...search } });
}
} catch (e) {
setIsLoading(false);
await message(String(e), { title: "Create Group", kind: "error" });
}
};
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="flex flex-col items-center justify-center text-center">
<h1 className="font-serif text-2xl font-medium">
Focus feeds for people you like
</h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Add some people for custom feeds.
</p>
</div>
<div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="flex items-center w-full rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
<label
htmlFor="name"
className="w-16 text-sm font-semibold text-center border-r border-black/10 dark:border-white/10 shrink-0"
>
Name
</label>
<input
name="name"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter a name for this group"
className="h-full px-3 text-sm bg-transparent border-none placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col items-center w-full gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
<div className="flex gap-2">
<input
name="npub"
value={npub}
onChange={(e) => setNpub(e.target.value)}
placeholder="npub1..."
className="w-full px-3 text-sm border-none rounded-lg h-9 bg-black/10 dark:bg-white/10 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => addUser()}
className="inline-flex items-center justify-center text-white rounded-lg size-9 bg-black/20 dark:bg-white/20 shrink-0 hover:bg-blue-500"
>
<PlusIcon className="size-6" />
</button>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold">Added</span>
<div className="flex flex-col gap-2">
{users.length ? (
users.map((item: string) => (
<button
key={item}
type="button"
onClick={() => toggleUser(item)}
className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5">
<User.Avatar className="object-cover rounded-full size-8" />
<div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" />
</div>
</User.Root>
</User.Provider>
<div>
<CancelIcon className="size-4" />
</div>
</button>
))
) : (
<div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
Empty.
</div>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold">Contacts</span>
<div className="flex flex-col gap-2">
{contacts.length ? (
contacts.map((item: string) => (
<button
key={item}
type="button"
onClick={() => toggleUser(item)}
className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5">
<User.Avatar className="object-cover rounded-full size-8" />
<div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" />
</div>
</User.Root>
</User.Provider>
</button>
))
) : (
<div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
<p>
Find more user at{" "}
<a
href="https://www.nostr.directory/"
target="_blank"
className="text-blue-600 after:content-['_↗']"
rel="noreferrer"
>
Nostr Directory
</a>
</p>
</div>
)}
</div>
</div>
</div>
<button
type="button"
onClick={() => submit()}
disabled={isLoading || users.length < 1}
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Confirm"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { NostrAccount } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
export const Route = createFileRoute("/create-newsfeed/f2f")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const { redirect } = Route.useSearch();
const [npub, setNpub] = useState("");
const [isLoading, setIsLoading] = useState(false);
const submit = async () => {
if (!npub.startsWith("npub1")) {
return await message("You must enter a valid npub.", {
title: "Create Newsfeed",
kind: "info",
});
}
try {
setIsLoading(true);
const sync = await NostrAccount.f2f(npub);
if (sync) {
return navigate({ to: redirect });
}
} catch (e) {
setIsLoading(false);
await message(String(e), {
title: "Create Newsfeed",
kind: "error",
});
}
};
return (
<div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<div className="flex flex-col justify-between h-full">
<div className="flex-1 flex flex-col gap-1.5 justify-center px-5">
<p className="font-semibold text-neutral-500">
You already have a friend on Nostr?
</p>
<p>Instead of building the timeline by yourself.</p>
<p className="font-semibold text-neutral-500">
Just enter your friend's{" "}
<span className="text-blue-500">npub.</span>
</p>
<p>
You will have the same experience as your friend. Of course, you
always can edit your network later.
</p>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<label htmlFor="npub" className="text-sm font-medium">
NPUB
</label>
<input
name="npub"
placeholder="npub1..."
value={npub}
onChange={(e) => setNpub(e.target.value)}
spellCheck={false}
className="px-3 bg-transparent border rounded-lg h-11 border-neutral-200 dark:border-neutral-800 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:placeholder:text-neutral-400"
/>
</div>
<button
type="button"
onClick={() => submit()}
className="inline-flex items-center justify-center w-full text-sm font-medium text-white bg-blue-500 rounded-lg h-9 hover:bg-blue-600"
>
{isLoading ? <Spinner /> : "Confirm"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import type { ColumnRouteSearch } from "@lume/types";
import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/create-newsfeed")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
function Screen() {
const search = Route.useSearch();
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="flex flex-col items-center justify-center text-center">
<h1 className="font-serif text-2xl font-medium">
Build up your timeline.
</h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Follow some people to keep up to date with them.
</p>
</div>
<div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-0.5">
<Link
to="/create-newsfeed/users"
search={search}
className="flex-1 h-8"
>
{({ isActive }) => (
<div
className={cn(
"text-sm font-medium rounded-md h-full flex items-center justify-center",
isActive
? "bg-white dark:bg-white/20 shadow"
: "bg-transparent",
)}
>
Users
</div>
)}
</Link>
<Link
to="/create-newsfeed/f2f"
search={search}
className="flex-1 h-8"
>
{({ isActive }) => (
<div
className={cn(
"rounded-md h-full flex items-center justify-center",
isActive ? "bg-white dark:bg-white/20" : "bg-transparent",
)}
>
Friend to Friend
</div>
)}
</Link>
</div>
<Outlet />
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { User } from "@/components/user";
import { NostrAccount } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { Await, defer } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { Suspense, useState } from "react";
export const Route = createFileRoute("/create-newsfeed/users")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
loader: async ({ abortController }) => {
try {
return {
data: defer(
fetch("https://api.nostr.band/v0/trending/profiles", {
signal: abortController.signal,
}).then((res) => res.json()),
),
};
} catch (e) {
throw new Error(String(e));
}
},
component: Screen,
});
function Screen() {
const { data } = Route.useLoaderData();
const { redirect } = Route.useSearch();
const [isLoading, setIsLoading] = useState(false);
const [follows, setFollows] = useState<string[]>([]);
const navigate = Route.useNavigate();
const toggleFollow = (pubkey: string) => {
setFollows((prev) =>
prev.includes(pubkey)
? prev.filter((i) => i !== pubkey)
: [...prev, pubkey],
);
};
const submit = async () => {
try {
setIsLoading(true);
const newContactList = await NostrAccount.setContactList(follows);
if (newContactList) {
return navigate({ to: redirect });
}
} catch (e) {
setIsLoading(false);
await message(String(e), {
title: "Create Group",
kind: "error",
});
}
};
return (
<div className="flex flex-col items-center w-full gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
<Suspense
fallback={
<div className="flex flex-col items-center justify-center w-full h-20 gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<Spinner className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(users) =>
users.profiles.map((item: { pubkey: string }) => (
<div
key={item.pubkey}
className="w-full p-2 mb-2 overflow-hidden bg-white rounded-lg h-max dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={item.pubkey}>
<User.Root>
<div className="flex flex-col w-full h-full gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User.Avatar className="object-cover rounded-full size-7 shrink-0" />
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
</div>
<button
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex items-center justify-center w-20 text-sm font-medium rounded-lg h-7 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
{follows.includes(item.pubkey)
? "Unfollow"
: "Follow"}
</button>
</div>
<User.About className="select-text line-clamp-3 max-w-none text-neutral-800 dark:text-neutral-400" />
</div>
</User.Root>
</User.Provider>
</div>
))
}
</Await>
</Suspense>
</div>
<button
type="button"
onClick={() => submit()}
disabled={isLoading || follows.length < 1}
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Confirm"}
</button>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { CheckCircleIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { TOPICS } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
type Topic = {
title: string;
content: string[];
};
export const Route = createFileRoute("/create-topic")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
function Screen() {
const [topics, setTopics] = useState<Topic[]>([]);
const [isLoading, setIsLoading] = useState(false);
const search = Route.useSearch();
const navigate = Route.useNavigate();
const toggleTopic = (topic: Topic) => {
setTopics((prev) =>
prev.find((item) => item.title === topic.title)
? prev.filter((i) => i.title !== topic.title)
: [...prev, topic],
);
};
const submit = async () => {
try {
setIsLoading(true);
const key = `lume_topic_${search.label}`;
const createTopic = await NostrQuery.setNstore(
key,
JSON.stringify(topics),
);
if (createTopic) {
return navigate({ to: search.redirect, search: { ...search } });
}
} catch (e) {
setIsLoading(false);
await message(String(e), {
title: "Create Topic",
kind: "error",
});
}
};
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="flex flex-col items-center justify-center text-center">
<h1 className="font-serif text-2xl font-medium">
What are your interests?
</h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Add some topics you want to focus on.
</p>
</div>
<div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="flex items-center justify-between w-full px-3 rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
<span className="text-sm font-medium">Added: {topics.length}</span>
</div>
<div className="flex flex-col items-center w-full gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
<div className="flex flex-col gap-3">
{TOPICS.map((topic) => (
<button
key={topic.title}
type="button"
onClick={() => toggleTopic(topic)}
className="flex items-center justify-between px-3 bg-white border border-transparent rounded-lg h-11 dark:bg-black/20 backdrop-blur-lg hover:border-blue-500 shadow-primary dark:ring-1 ring-neutral-800/50"
>
<div className="inline-flex items-center gap-1">
<div>{topic.icon}</div>
<div className="text-sm font-medium">
<span>{topic.title}</span>
<span className="ml-1 italic font-normal text-neutral-400 dark:text-neutral-600">
{topic.content.length} hashtags
</span>
</div>
</div>
{topics.find((item) => item.title === topic.title) ? (
<CheckCircleIcon className="text-teal-500 size-4" />
) : null}
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => submit()}
disabled={isLoading || topics.length < 1}
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Confirm"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,78 +1,77 @@
import { useArk } from "@lume/ark";
import { AddMediaIcon, LoaderIcon } from "@lume/icons";
import { cn, insertImage, isImagePath } from "@lume/utils";
import { AddMediaIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import { insertImage, isImagePath } from "@lume/utils";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react";
import { useSlateStatic } from "slate-react";
import { toast } from "sonner";
import { getCurrent } from "@tauri-apps/api/window";
import { UnlistenFn } from "@tauri-apps/api/event";
export function MediaButton({ className }: { className?: string }) {
const ark = useArk();
const editor = useSlateStatic();
export function MediaButton() {
const editor = useSlateStatic();
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false);
const upload = async () => {
try {
// start loading
setLoading(true);
const uploadToNostrBuild = async () => {
try {
setLoading(true);
const image = await NostrQuery.upload();
insertImage(editor, image);
const image = await ark.upload();
// reset loading
setLoading(false);
} catch (e) {
setLoading(false);
await message(String(e), { title: "Upload", kind: "error" });
}
};
if (image) {
insertImage(editor, image);
}
useEffect(() => {
let unlisten: UnlistenFn = undefined;
setLoading(false);
} catch (e) {
setLoading(false);
toast.error(`Upload failed, error: ${e}`);
}
};
async function listenFileDrop() {
const window = getCurrent();
if (!unlisten) {
unlisten = await window.listen("tauri://file-drop", async (event) => {
// @ts-ignore, lfg !!!
const items: string[] = event.payload.paths;
// start loading
setLoading(true);
// upload all images
for (const item of items) {
if (isImagePath(item)) {
const image = await NostrQuery.upload(item);
insertImage(editor, image);
}
}
// stop loading
setLoading(false);
});
}
}
useEffect(() => {
let unlisten: UnlistenFn = undefined;
listenFileDrop();
async function listenFileDrop() {
const window = getCurrent();
if (!unlisten) {
unlisten = await window.listen("tauri://file-drop", async (event) => {
// @ts-ignore, lfg !!!
const items: string[] = event.payload.paths;
// start loading
setLoading(true);
// upload all images
for (const item of items) {
if (isImagePath(item)) {
const image = await ark.upload(item);
insertImage(editor, image);
}
}
// stop loading
setLoading(false);
});
}
}
return () => {
if (unlisten) unlisten();
};
}, []);
listenFileDrop();
return () => {
if (unlisten) unlisten();
};
}, []);
return (
<button
type="button"
onClick={() => uploadToNostrBuild()}
disabled={loading}
className={cn("inline-flex items-center justify-center", className)}
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
<AddMediaIcon className="size-5" />
)}
</button>
);
return (
<button
type="button"
onClick={() => upload()}
disabled={loading}
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
>
{loading ? (
<Spinner className="size-4" />
) : (
<AddMediaIcon className="size-4" />
)}
Add media
</button>
);
}

View File

@@ -0,0 +1,21 @@
import { PowIcon } from "@lume/icons";
import type { Dispatch, SetStateAction } from "react";
export function PowButton({
setDifficulty,
}: {
setDifficulty: Dispatch<SetStateAction<{ enable: boolean; num: number }>>;
}) {
return (
<button
type="button"
onClick={() =>
setDifficulty((prev) => ({ ...prev, enable: !prev.enable }))
}
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
>
<PowIcon className="size-4" />
PoW
</button>
);
}

View File

@@ -0,0 +1,19 @@
import { NsfwIcon } from "@lume/icons";
import type { Dispatch, SetStateAction } from "react";
export function WarningButton({
setWarning,
}: {
setWarning: Dispatch<SetStateAction<{ enable: boolean; reason: string }>>;
}) {
return (
<button
type="button"
onClick={() => setWarning((prev) => ({ ...prev, enable: !prev.enable }))}
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
>
<NsfwIcon className="size-4" />
Mark as sensitive
</button>
);
}

View File

@@ -1,438 +1,399 @@
import { useArk } from "@lume/ark";
import { LoaderIcon, TrashIcon } from "@lume/icons";
import {
Portal,
cn,
insertImage,
insertMention,
insertNostrEvent,
isImageUrl,
sendNativeNotification,
} from "@lume/utils";
import { ComposeFilledIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { MediaButton } from "./-components/media";
import { MentionNote } from "@lume/ui/src/note/mentions/note";
import { useEffect, useState } from "react";
import { type Descendant, Node, Transforms, createEditor } from "slate";
import {
Descendant,
Editor,
Node,
Range,
Transforms,
createEditor,
} from "slate";
import {
ReactEditor,
useSlateStatic,
useSelected,
useFocused,
withReact,
Slate,
Editable,
Editable,
ReactEditor,
Slate,
useFocused,
useSelected,
useSlateStatic,
withReact,
} from "slate-react";
import { Contact } from "@lume/types";
import { User } from "@lume/ui";
import { MediaButton } from "./-components/media";
import { LumeEvent, useEvent } from "@lume/system";
import { WarningButton } from "./-components/warning";
import { MentionNote } from "@/components/note/mentions/note";
import { PowButton } from "./-components/pow";
import { User } from "@/components/user";
import { Note } from "@/components/note";
import { nip19 } from "nostr-tools";
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
type EditorElement = {
type: string;
children: Descendant[];
eventId?: string;
type EditorSearch = {
reply_to: string;
quote: string;
};
const contactQueryOptions = queryOptions({
queryKey: ["contacts"],
queryFn: () => invoke("get_contact_metadata"),
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
type EditorElement = {
type: string;
children: Descendant[];
eventId?: string;
};
export const Route = createFileRoute("/editor/")({
loader: ({ context }) =>
context.queryClient.ensureQueryData(contactQueryOptions),
component: Screen,
pendingComponent: Pending,
validateSearch: (search: Record<string, string>): EditorSearch => {
return {
reply_to: search.reply_to,
quote: search.quote,
};
},
beforeLoad: ({ search }) => {
let initialValue: EditorElement[];
if (search?.quote?.length) {
const eventId = nip19.noteEncode(search.quote);
initialValue = [
{
type: "paragraph",
children: [{ text: "" }],
},
{
type: "event",
eventId: `nostr:${eventId}`,
children: [{ text: "" }],
},
];
} else {
initialValue = [
{
type: "paragraph",
children: [{ text: "" }],
},
];
}
return { initialValue };
},
component: Screen,
});
function Screen() {
// @ts-ignore, useless
const { reply_to, quote } = Route.useSearch();
const { reply_to } = Route.useSearch();
const { initialValue } = Route.useRouteContext();
let initialValue: EditorElement[];
const [editorValue, setEditorValue] = useState<EditorElement[]>(null);
const [loading, setLoading] = useState(false);
const [warning, setWarning] = useState({ enable: false, reason: "" });
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
);
if (quote) {
initialValue = [
{
type: "paragraph",
children: [{ text: "" }],
},
{
type: "event",
eventId: `nostr:${nip19.noteEncode(reply_to)}`,
children: [{ text: "" }],
},
{
type: "paragraph",
children: [{ text: "" }],
},
];
} else {
initialValue = [
{
type: "paragraph",
children: [{ text: "" }],
},
];
}
const reset = () => {
// @ts-expect-error, backlog
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
};
const ark = useArk();
const ref = useRef<HTMLDivElement | null>();
const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[];
const serialize = (nodes: Descendant[]) => {
return nodes
.map((n) => {
// @ts-expect-error, backlog
if (n.type === "image") return n.url;
// @ts-expect-error, backlog
if (n.type === "event") return n.eventId;
const [t] = useTranslation();
const [editorValue, setEditorValue] = useState(initialValue);
const [target, setTarget] = useState<Range | undefined>();
const [index, setIndex] = useState(0);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
);
// @ts-expect-error, backlog
if (n.children.length) {
// @ts-expect-error, backlog
return n.children
.map((n) => {
if (n.type === "mention") return n.npub;
return Node.string(n).trim();
})
.join(" ");
}
const filters = contacts
?.filter((c) =>
c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()),
)
?.slice(0, 5);
return Node.string(n);
})
.join("\n");
};
const reset = () => {
// @ts-expect-error, backlog
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
};
const publish = async () => {
try {
// start loading
setLoading(true);
const serialize = (nodes: Descendant[]) => {
return nodes
.map((n) => {
// @ts-expect-error, backlog
if (n.type === "image") return n.url;
// @ts-expect-error, backlog
if (n.type === "event") return n.eventId;
const content = serialize(editor.children);
const eventId = await LumeEvent.publish(
content,
warning.enable && warning.reason.length ? warning.reason : null,
difficulty.enable && difficulty.num > 0 ? difficulty.num : null,
reply_to,
);
// @ts-expect-error, backlog
if (n.children.length) {
// @ts-expect-error, backlog
return n.children
.map((n) => {
if (n.type === "mention") return n.npub;
return Node.string(n).trim();
})
.join(" ");
}
if (eventId) {
// stop loading
setLoading(false);
// reset form
reset();
}
} catch (e) {
setLoading(false);
}
};
return Node.string(n);
})
.join("\n");
};
useEffect(() => {
setEditorValue(initialValue);
}, [initialValue]);
const publish = async () => {
try {
// start loading
setLoading(true);
if (!editorValue) return null;
const content = serialize(editor.children);
const eventId = await ark.publish(content, reply_to, quote);
if (eventId) {
await sendNativeNotification("You've publish new post successfully.");
return reset();
}
// stop loading
setLoading(false);
} catch (e) {
setLoading(false);
await sendNativeNotification(String(e));
}
};
useEffect(() => {
if (target && filters.length > 0) {
const el = ref.current;
const domRange = ReactEditor.toDOMRange(editor, target);
const rect = domRange.getBoundingClientRect();
el.style.top = `${rect.top + window.scrollY + 24}px`;
el.style.left = `${rect.left + window.scrollX}px`;
}
}, [filters.length, editor, index, search, target]);
return (
<div className="flex h-screen w-screen flex-col bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900">
<Slate
editor={editor}
initialValue={editorValue}
onChange={() => {
const { selection } = editor;
if (selection && Range.isCollapsed(selection)) {
const [start] = Range.edges(selection);
const wordBefore = Editor.before(editor, start, { unit: "word" });
const before = wordBefore && Editor.before(editor, wordBefore);
const beforeRange = before && Editor.range(editor, before, start);
const beforeText =
beforeRange && Editor.string(editor, beforeRange);
const beforeMatch = beforeText?.match(/^@(\w+)$/);
const after = Editor.after(editor, start);
const afterRange = Editor.range(editor, start, after);
const afterText = Editor.string(editor, afterRange);
const afterMatch = afterText.match(/^(\s|$)/);
if (beforeMatch && afterMatch) {
setTarget(beforeRange);
setSearch(beforeMatch[1]);
setIndex(0);
return;
}
}
setTarget(null);
}}
>
<div
data-tauri-drag-region
className="flex h-16 w-full shrink-0 items-center justify-end gap-3 px-2"
>
<MediaButton className="size-9 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" />
<button
type="button"
onClick={publish}
className="inline-flex h-9 w-24 items-center justify-center rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
t("global.post")
)}
</button>
</div>
<div className="flex h-full min-h-0 w-full">
<div className="flex h-full w-full flex-1 flex-col gap-2 px-2 pb-2">
{reply_to && !quote ? (
<div className="flex flex-col rounded-xl bg-white p-5 shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
<h3 className="font-medium">Reply to:</h3>
<MentionNote eventId={reply_to} />
</div>
) : null}
<div className="h-full w-full flex-1 overflow-hidden overflow-y-auto rounded-xl bg-white p-5 shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={true}
autoCapitalize="none"
autoCorrect="none"
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={t("editor.placeholder")}
className="focus:outline-none"
/>
{target && filters.length > 0 && (
<Portal>
<div
ref={ref}
className="absolute left-[-9999px] top-[-9999px] z-10 w-[250px] rounded-xl border border-neutral-50 bg-white p-2 shadow-lg dark:border-neutral-900 dark:bg-neutral-950"
>
{filters.map((contact) => (
<button
key={contact.pubkey}
type="button"
onClick={() => {
Transforms.select(editor, target);
insertMention(editor, contact);
setTarget(null);
}}
className="flex w-full flex-col rounded-lg p-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<User.Provider pubkey={contact.pubkey}>
<User.Root className="flex w-full items-center gap-2">
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" />
<div className="flex w-full flex-col items-start">
<User.Name className="max-w-[8rem] truncate text-sm font-medium" />
</div>
</User.Root>
</User.Provider>
</button>
))}
</div>
</Portal>
)}
</div>
</div>
</div>
</Slate>
</div>
);
return (
<div className="flex flex-col w-full h-full">
<Slate editor={editor} initialValue={editorValue}>
<div data-tauri-drag-region className="h-9 shrink-0" />
<div className="flex flex-col flex-1 overflow-y-auto">
{reply_to?.length ? (
<div className="flex items-center gap-3 px-2.5 pb-3 border-b border-black/5 dark:border-white/5">
<div className="text-sm font-semibold shrink-0">Reply to:</div>
<ChildNote id={reply_to} />
</div>
) : null}
<div className="px-4 py-4 overflow-y-auto">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={true}
autoCapitalize="none"
autoCorrect="none"
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={
reply_to ? "Type your reply..." : "What're you up to?"
}
className="focus:outline-none"
/>
</div>
</div>
{warning.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Reason:
</span>
<input
type="text"
placeholder="NSFW..."
value={warning.reason}
onChange={(e) =>
setWarning((prev) => ({ ...prev, reason: e.target.value }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
{difficulty.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Difficulty:
</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]"
onKeyDown={(event) => {
if (!/[0-9]/.test(event.key)) {
event.preventDefault();
}
}}
placeholder="21"
defaultValue={difficulty.num}
onChange={(e) =>
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
<div
data-tauri-drag-region
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
>
<button
type="button"
onClick={() => publish()}
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
{loading ? (
<Spinner className="size-4" />
) : (
<ComposeFilledIcon className="size-4" />
)}
Publish
</button>
<div className="inline-flex items-center flex-1 gap-2 pl-4">
<MediaButton />
<WarningButton setWarning={setWarning} />
<PowButton setDifficulty={setDifficulty} />
</div>
</div>
</Slate>
</div>
);
}
function Pending() {
return (
<div className="flex h-full w-full items-center justify-center gap-2.5">
<LoaderIcon className="size-5 animate-spin" />
<p>Loading cache...</p>
</div>
);
function ChildNote({ id }: { id: string }) {
const { isLoading, isError, data } = useEvent(id);
if (isLoading) {
return <Spinner className="size-5" />;
}
if (isError || !data) {
return <div>Event not found with your current relay set.</div>;
}
return (
<Note.Provider event={data}>
<Note.Root className="flex items-center gap-2">
<User.Provider pubkey={data.pubkey}>
<User.Root className="shrink-0">
<User.Avatar className="rounded-full size-8 shrink-0" />
</User.Root>
</User.Provider>
<div className="content-break line-clamp-1">{data.content}</div>
</Note.Root>
</Note.Provider>
);
}
const withNostrEvent = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "event" ? true : isVoid(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "event" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (text.startsWith("nevent1") || text.startsWith("note1")) {
insertNostrEvent(editor, text);
} else {
insertData(data);
}
};
if (text.startsWith("nevent") || text.startsWith("note")) {
insertNostrEvent(editor, text);
} else {
insertData(data);
}
};
return editor;
return editor;
};
const withMentions = (editor: ReactEditor) => {
const { isInline, isVoid, markableVoid } = editor;
const { isInline, isVoid, markableVoid } = editor;
editor.isInline = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isInline(element);
};
editor.isInline = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isInline(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isVoid(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isVoid(element);
};
editor.markableVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" || markableVoid(element);
};
editor.markableVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" || markableVoid(element);
};
return editor;
return editor;
};
const withImages = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "image" ? true : isVoid(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "image" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (isImageUrl(text)) {
insertImage(editor, text);
} else {
insertData(data);
}
};
if (isImageUrl(text)) {
insertImage(editor, text);
} else {
insertData(data);
}
};
return editor;
return editor;
};
const Image = ({ attributes, children, element }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const Image = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const selected = useSelected();
const focused = useFocused();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const selected = useSelected();
const focused = useFocused();
return (
<div {...attributes}>
{children}
<div contentEditable={false} className="relative my-2">
<img
src={element.url}
alt={element.url}
className={cn(
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
contentEditable={false}
/>
<button
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
>
<TrashIcon className="size-4" />
</button>
</div>
</div>
);
return (
<div {...attributes}>
{children}
<img
src={element.url}
alt={element.url}
className={cn(
"my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
onClick={() => Transforms.removeNodes(editor, { at: path })}
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
/>
</div>
);
};
const Mention = ({ attributes, element }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<span
{...attributes}
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
>{`@${element.name}`}</span>
);
return (
<span
{...attributes}
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
>{`@${element.name}`}</span>
);
};
const Event = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<div {...attributes}>
{children}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
<div
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="user-select-none relative my-2"
>
<MentionNote
eventId={element.eventId.replace("nostr:", "")}
openable={false}
/>
</div>
</div>
);
return (
<div {...attributes}>
{children}
<div
contentEditable={false}
className="relative my-2 user-select-none"
onClick={() => Transforms.removeNodes(editor, { at: path })}
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
>
<MentionNote eventId={element.eventId} openable={false} />
</div>
</div>
);
};
const Element = (props) => {
const { attributes, children, element } = props;
const { attributes, children, element } = props;
switch (element.type) {
case "image":
return <Image {...props} />;
case "mention":
return <Mention {...props} />;
case "event":
return <Event {...props} />;
default:
return (
<p {...attributes} className="text-lg">
{children}
</p>
);
}
switch (element.type) {
case "image":
return <Image {...props} />;
case "mention":
return <Mention {...props} />;
case "event":
return <Event {...props} />;
default:
return (
<p {...attributes} className="text-[15px]">
{children}
</p>
);
}
};

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