Compare commits

..
242 Commits
Author SHA1 Message Date
reya 72da83d648 feat: add force quit command 2024-07-02 17:24:55 +07:00
reya 783a4538a4 Revert "chore: update ci"
This reverts commit 04706a6d7c.
2024-07-02 15:54:00 +07:00
reya 15e62cad11 fix: temporary include devtools in release build 2024-07-02 14:58:00 +07:00
reya c52b20ca80 chore: bump version 2024-07-02 14:36:16 +07:00
reya 04706a6d7c chore: update ci 2024-07-02 14:07:12 +07:00
reya 0755cbeb6c chore: bump version 2024-07-02 13:03:45 +07:00
雨宮蓮andGitHub 8eb01c8bbf Improve column management (#221)
* wip: redesign store

* feat: update trending column

* feat: add more functions
2024-07-02 12:51:50 +07:00
reya ed4f89ff66 feat: add option to toggle window transparent 2024-07-02 08:49:52 +07:00
reya d9fe647f8e feat: few improvements 2024-07-01 14:41:33 +07:00
reya 843c2d52e7 refactor: tray panel 2024-07-01 13:04:32 +07:00
reya 017a3676a4 feat: optimize spawn thread 2024-06-30 21:06:04 +07:00
reya fcb70c0e9a chore: update deps 2024-06-30 14:37:26 +07:00
雨宮蓮andGitHub 0fec21b9ce Some improments and Negentropy (#219)
* feat: adjust default window size

* feat: save window state

* feat: add window state plugin

* feat: add search

* feat: use negentropy for newsfeed

* feat: live feeds

* feat: add search user
2024-06-30 14:26:02 +07:00
XIAO YUandGitHub 968b1ada94 refactor: improve relay management code structure (#220) 2024-06-29 07:41:16 +07:00
reya 5c9b599b1e feat: use memo for some components 2024-06-26 17:49:36 +07:00
雨宮蓮andGitHub 717c3e17df Event Subscriptions (#218)
* feat: improve create column command

* refactor: thread

* feat: add window virtualized to event screen

* chore: update deps

* fix: window decoration

* feat: improve mention ntoe

* feat: add subscription to event screen
2024-06-26 14:51:50 +07:00
XIAO YUandGitHub a4540a0802 refactor: improve error handling in event.rs (#217) 2024-06-26 07:57:53 +07:00
reya 31bacc2646 chore: remove unused code 2024-06-24 11:09:32 +07:00
reya 6e5d0f0e76 chore: bump version 2024-06-24 10:06:12 +07:00
XIAO YUandGitHub f0712e5961 refactor: improve error handling (#216) 2024-06-24 07:19:36 +07:00
雨宮蓮andGitHub 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
雨宮蓮andGitHub 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
雨宮蓮andGitHub f8280ec8ee fix: get replies function (#213) 2024-06-19 21:02:33 +07:00
XIAO YUandGitHub 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
雨宮蓮andGitHub 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
雨宮蓮andGitHub 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 YUandGitHub 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
雨宮蓮andGitHub 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 YUandGitHub 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 YUandGitHub 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 YUandGitHub 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
雨宮蓮andGitHub 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 YUandGitHub b90ad1421f chore: update keys.rs and relay.rs (#193) 2024-05-26 07:28:16 +07:00
雨宮蓮andGitHub 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 YUandGitHub 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
雨宮蓮andGitHub 407fe40b67 refactor: account managements (#190)
* feat: add keyring-search

* feat: improve nostr connect
2024-05-22 13:24:58 +07:00
雨宮蓮andGitHub 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
雨宮蓮andGitHub 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
雨宮蓮andGitHub 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
雨宮蓮andGitHub 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
雨宮蓮andGitHub 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
雨宮蓮andGitHub f027eae52d feat: use negentropy (#182)
* feat: use negentropy

* chore: polish
2024-04-24 10:18:51 +07:00
雨宮蓮andGitHub 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
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
reya 17766d29d6 chore: update deps 2024-04-22 13:18:34 +07:00
reya 3b13dfeed8 chore: update github action 2024-04-20 18:13:36 +07:00
reya 17ba79e01b chore: update app icon 2024-04-20 08:58:00 +07:00
reya bafad544e9 chore: update deps and bump version 2024-04-19 14:21:12 +07:00
reya 89c36423ae feat: add nip46 2024-04-18 15:09:33 +07:00
reya cd31b99559 feat: improve official columns 2024-04-18 07:50:46 +07:00
reya f3c52237fa fix: privacy setting is not working 2024-04-17 13:50:50 +07:00
reya 413d8d82df feat: finish settings screen 2024-04-16 15:28:38 +07:00
reya 2eb2010d43 chore: upgrade deps 2024-04-16 08:55:36 +07:00
reya 94d400cab2 feat: support nip-36 2024-04-16 07:49:44 +07:00
reya 09b143cb08 feat: auto resize mini webview when main webview resized 2024-04-15 13:30:55 +07:00
reya e3ede34108 feat: revert to sqlite 2024-04-14 09:05:46 +07:00
reya ed6aca41ea feat: add new spinner component 2024-04-13 15:01:27 +07:00
reya 89f577fbef feat: upgrade to rust nostr 0.30.0 and migrate to nostrdb 2024-04-13 08:53:31 +07:00
reya 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
reya 8a7b246315 chore: update deps 2024-04-11 07:47:37 +07:00
Ren AmamiyaandGitHub d98c6d0709 Merge pull request #175 from lumehq/v4/beta
Prepare for v4 - beta
2024-04-11 07:45:43 +07:00
reya bda20e8fe8 chore: small updates 2024-04-11 07:44:41 +07:00
reya c342daecc8 feat: improve 2024-04-10 14:11:05 +07:00
reya 5e6692cd6d feat: re add group column 2024-04-09 14:05:50 +07:00
reya 420be77b5c feat: add nstore 2024-04-07 15:11:20 +07:00
reya 999073f84c feat: readd for you column 2024-04-04 13:47:15 +07:00
reya 174b28f1a7 chore: update deps 2024-04-03 08:01:40 +07:00
reya 763cb10e85 chore: clean up 2024-04-03 07:29:46 +07:00
reya 89bb8d88f6 feat: settings screens 2024-04-02 13:19:26 +07:00
reya 09aa2ecafc feat: re-add trending column 2024-03-30 10:50:45 +07:00
reya 7271e9ea87 feat: add custom traffic light inset for macos 2024-03-29 13:26:35 +07:00
reya a02e496b29 chore: update tauri 2024-03-29 07:45:37 +07:00
reya cbbf5eaf50 feat: add create account flow 2024-03-28 15:12:43 +07:00
reya d3fa59d2b1 chore: polish 2024-03-27 08:36:34 +07:00
reya aa23e39334 feat: update new icons 2024-03-26 14:58:06 +07:00
reya c8e2018fd0 feat: update tray icon 2024-03-26 13:08:03 +07:00
reya 6e489f1c49 wip: onboarding 2024-03-25 14:22:06 +07:00
reya a49b88ab35 Merge branch 'main' into v4/beta 2024-03-23 07:24:58 +07:00
reya 31839531ea feat: improve open store column 2024-03-22 15:25:53 +07:00
reya ec0f3fabc0 feat: improve column management 2024-03-22 11:28:54 +07:00
reya dd7155a3a6 feat: update default column informations 2024-03-21 14:53:08 +07:00
reya cb565ff35b feat: readd default columns 2024-03-20 15:12:54 +07:00
reya 5d59040224 chore: follow up 2024-03-19 15:36:20 +07:00
reya 7fabf949c6 feat: move column manager to rust 2024-03-19 14:56:24 +07:00
reya ea5120e2f0 chore: upgrade tauri and bump version 2024-03-19 12:46:19 +07:00
reya 14f07dfea8 feat: add lume store screen 2024-03-19 08:21:16 +07:00
Ren AmamiyaandGitHub 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
reya 05b52564e0 feat: column manager 2024-03-18 10:50:08 +07:00
reya c8e014f33e chore: clean up 2024-03-16 18:45:54 +07:00
reya 46cc01e0ee feat: child webview 2024-03-16 15:05:06 +07:00
reya 16e6d234e5 feat: space 2024-03-14 14:22:41 +07:00
reya 3005d27403 feat: update fetch opg function 2024-03-13 10:24:45 +07:00
reya 1be84f3139 chore: release v4.0.0-alpha+2 2024-03-13 09:40:30 +07:00
Ren AmamiyaandGitHub 04bde7dd43 Merge pull request #172 from lumehq/v4/functions
Some polish for v4
2024-03-13 09:39:57 +07:00
reya f1504d99ac feat: add mock tray menu 2024-03-13 09:19:25 +07:00
reya e928f2ee37 feat: polish 2024-03-13 08:40:18 +07:00
Ren AmamiyaandGitHub ccab78ca11 Merge pull request #171 from lumehq/v4/zap
Add zap to v4
2024-03-11 15:39:35 +07:00
reya 5b13c3e45c feat: auto close window if zapp successfully 2024-03-11 15:38:54 +07:00
reya e6b97ab9ae feat: add zap 2024-03-11 15:36:17 +07:00
reya bb6badfed6 feat: add zap setup screen 2024-03-10 16:39:23 +07:00
Ren AmamiyaandGitHub 0c4b309a11 Merge pull request #170 from lumehq/v4/home-screen
v4/home-screen
2024-03-09 10:09:29 +07:00
reya f4400d711f feat: improve home screen 2024-03-09 10:08:50 +07:00
reya a3e46aa96b fix: crash when database is not exist 2024-03-08 14:47:36 +07:00
reya a4fdcfdf0b feat: add local and global newsfeeds 2024-03-07 15:39:43 +07:00
reya 25d07303a3 fix: build error in windows and linux 2024-03-06 15:32:51 +07:00
reya 0e1e7524c9 chore: bump version 2024-03-06 15:05:17 +07:00
Ren AmamiyaandGitHub 32d770eb91 Merge pull request #169 from lumehq/v4/onboarding
Add basic onboarding dialog to v4
2024-03-06 15:00:07 +07:00
reya e93cbf78d5 feat: add npub to file associations 2024-03-06 14:40:55 +07:00
reya 71a2290b8f feat: add backup dialog 2024-03-06 14:40:00 +07:00
reya 95294a80cb feat: simplify account management 2024-03-06 11:11:24 +07:00
reya 8eaf47f6d2 feat: add login dialog 2024-03-06 09:42:44 +07:00
reya 86183d799a fix: package version in windows 2024-03-02 17:59:36 +07:00
reya 2dbfff3c17 chore: release v4.0.0-alpha.0 2024-03-02 16:08:01 +07:00
Ren AmamiyaandGitHub d1f5c372ae Merge pull request #166 from lumehq/feat/rust-nostr
Rust Nostr
2024-03-02 15:32:13 +07:00
reya 13281455ed chore: refer old branch in readme 2024-03-02 15:31:41 +07:00
reya 9cfb32be4d feat: update readme 2024-03-02 15:29:45 +07:00
reya cb0672c22a Merge branch 'main' into feat/rust-nostr 2024-03-02 15:22:11 +07:00
reya ca0e041731 feat: improve 2024-03-02 15:21:28 +07:00
reya cfcb9bc6ed feat: improve ui 2024-02-29 13:02:16 +07:00
reya 09df8672d0 feat: add zap command 2024-02-27 15:37:49 +07:00
reya 2403231ac4 feat: add user screen 2024-02-27 13:42:59 +07:00
reya 98ef1927f2 feat: editor 2024-02-26 15:10:42 +07:00
reya 63db8b1423 chore: clean up 2024-02-25 15:52:47 +07:00
reya 2c8dd71792 feat: add simple tray icon 2024-02-25 09:06:23 +07:00
reya fe73e1428b feat: upgrade tauri 2024-02-24 18:26:20 +07:00
reya 88a6c3c81f feat: account switcher 2024-02-24 14:41:57 +07:00
reya 84584a4d1f feat: add editor screen 2024-02-23 14:56:24 +07:00
reya 64286aa354 wip 2024-02-22 14:01:29 +07:00
reya e9ce932646 chore: remove storage layer 2024-02-22 08:58:45 +07:00
reya 9127d5c5ea feat: add more metadata commands 2024-02-21 14:29:54 +07:00
reya 090e54ec5a feat: add contact list commands 2024-02-20 14:46:21 +07:00
reya d0c9f93ebb wip: small updates 2024-02-20 10:26:19 +07:00
reya e2cdc5b576 feat: account selection screen 2024-02-19 14:13:33 +07:00
reya bfe35ad885 wip: multi accounts 2024-02-19 09:58:27 +07:00
reya 0f06522c28 wip: refactor 2024-02-17 16:01:53 +07:00
reya f47eba5af7 feat: upgrade to rust nostr 0.28 2024-02-17 09:29:37 +07:00
reya f28a7ae82f wip: update design 2024-02-16 14:11:49 +07:00
reya 296b11b7b8 wip: basic support multi windows 2024-02-16 09:58:07 +07:00
reya a0d9a729dd feat: add tauri devtools 2024-02-15 14:41:32 +07:00
reya 1de8c7240d wip: desktop2 2024-02-15 14:32:40 +07:00
reya 70126ef1b3 Merge branch 'main' into feat/rust-nostr 2024-02-15 12:48:20 +07:00
reya cdf29f8a54 wip: update 2024-02-15 12:47:15 +07:00
reya 6171b9bed1 wip: tired... 2024-02-14 15:51:06 +07:00
reya 60fd09000b wip: new design 2024-02-14 08:29:24 +07:00
reya 4292def206 feat: add more nostr commands 2024-02-13 09:36:11 +07:00
reya 90f149b09c chore: clean up 2024-02-13 08:57:24 +07:00
reya ed52105c02 wip: migrate to desktop2 2024-02-12 20:04:45 +07:00
reya 1950cb59a2 wip: migrate to desktop2 2024-02-12 09:08:35 +07:00
reya c809ab6b4e feat: add desktop2 2024-02-11 09:30:18 +07:00
reya 35c5b5fb78 chore: fix build 2024-02-10 11:19:18 +07:00
reya 739ba63e6c wip: migrate frontend to new backend 2024-02-09 15:33:27 +07:00
reya ec78cf8bf7 feat: migrate frontend to new backend 2024-02-08 21:24:08 +07:00
reya 17052aeeaa feat: add new account management 2024-02-08 17:17:45 +07:00
reya d7bbda6e7b fix: remove native db 2024-02-07 14:26:58 +07:00
reya 6a08c1de10 feat: add all nostr events command 2024-02-07 14:04:57 +07:00
reya 3c4bd39384 feat: update rust nostr 2024-02-06 19:28:46 +07:00
reya a4069dae99 chore: clean up 2024-02-06 09:15:20 +07:00
reya a21da11a91 feat: add desktop2 2024-02-05 14:18:27 +07:00
reya 08fa7de01d feat: add check active account before init client 2024-02-05 13:36:10 +07:00
reya 2a58326cd1 chore: update dependencies 2024-02-05 08:30:06 +07:00
reya 8a17c36a5b feat: upgrade to tauri beta 2024-02-05 08:14:58 +07:00
reya 7bd6f6a8db fix: remove surrealdb and clean up 2024-02-05 07:46:41 +07:00
reya 3ba870be4b feat: add verify signer 2024-02-04 10:46:50 +07:00
reya bd2b6a3759 feat: add surreal db 2024-02-03 11:24:08 +07:00
reya a3a8f57bfc feat: add more nostr commands 2024-02-03 09:13:17 +07:00
reya fd393a4d30 feat: add basic nostr connection 2024-02-02 09:09:14 +07:00
reya e8bd48e51b feat: add rust nostr 2024-02-01 12:54:25 +07:00
610 changed files with 47015 additions and 28070 deletions
+38 -35
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
+3 -1
View File
@@ -29,8 +29,10 @@ dist/
# Debug
*.log*
*.log.*
# Misc
.DS_Store
*.pem
.vscode/
.idea/
+19 -13
View File
@@ -1,20 +1,26 @@
### Introduction
## Introduction
Lume is a nostr client
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
## Usage
Download Lume 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.
### Prerequisites
Windows and Linux are availabel on v3 and below.
- PNPM or Bun (experiment)
## Prerequisites
- Tauri: https://tauri.app/v1/guides/getting-started/prerequisites#setting-up-macos
- Node.js >= 18: https://nodejs.org/en
### Develop
- Rust: https://rustup.rs/
- PNPM: https://pnpm.io
- Tauri v2: https://beta.tauri.app/guides/prerequisites/
## Develop
Clone project
@@ -40,7 +46,7 @@ Generate production build
pnpm tauri build
```
#### Nix
## Nix
Requirements:
@@ -53,8 +59,8 @@ Requirements:
Copyright (C) 2023-2024 Ren Amamiya & other Lume contributors (see AUTHORS.md)
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see https://www.gnu.org/licenses/.
You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.
-13
View File
@@ -1,13 +0,0 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lume</title>
</head>
<body
class="relative w-screen h-screen overflow-hidden font-sans antialiased cursor-default select-none text-neutral-950 dark:text-neutral-50"
>
<div id="root"></div>
<script type="module" src="./src/main.jsx"></script>
</body>
</html>
-77
View File
@@ -1,77 +0,0 @@
{
"name": "lume",
"private": true,
"version": "3.0.0",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@columns/antenas": "workspace:^",
"@columns/default": "workspace:^",
"@columns/foryou": "workspace:^",
"@columns/global": "workspace:^",
"@columns/group": "workspace:^",
"@columns/hashtag": "workspace:^",
"@columns/thread": "workspace:^",
"@columns/timeline": "workspace:^",
"@columns/trending-notes": "workspace:^",
"@columns/user": "workspace:^",
"@columns/waifu": "workspace:^",
"@getalby/sdk": "^3.2.3",
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^",
"@lume/storage": "workspace:^",
"@lume/ui": "workspace:^",
"@lume/utils": "workspace:^",
"@nostr-dev-kit/ndk": "^2.3.3",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@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-hover-card": "^1.0.7",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.17.19",
"framer-motion": "^11.0.3",
"i18next": "^23.8.1",
"i18next-resources-to-backend": "^1.2.0",
"jotai": "^2.6.3",
"minidenticons": "^4.2.0",
"nanoid": "^5.0.4",
"nostr-fetch": "^0.15.0",
"nostr-tools": "^1.17.0",
"react": "^18.2.0",
"react-currency-input-field": "^3.6.14",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.3",
"react-i18next": "^14.0.1",
"react-router-dom": "^6.21.3",
"smol-toml": "^1.1.4",
"sonner": "^1.4.0",
"virtua": "^0.23.0"
},
"devDependencies": {
"@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@types/node": "^20.11.10",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.17",
"cross-env": "^7.0.3",
"encoding": "^0.1.13",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.1"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

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: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

-48
View File
@@ -1,48 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.break-p {
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
}
.prose :where(iframe):not(:where([class~='not-prose'] *)) {
@apply w-full h-auto mx-auto aspect-video;
}
.shadow-toolbar {
box-shadow: 0 0 #0000, 0 0 #0000, 0 8px 24px 0 rgba(0, 0, 0, .2), 0 2px 8px 0 rgba(0, 0, 0, .08), inset 0 0 0 1px rgba(0, 0, 0, .2), inset 0 0 0 2px hsla(0, 0%, 100%, .14)
}
}
html {
font-size: 14px;
}
a {
@apply cursor-default no-underline !important;
}
button {
@apply cursor-default focus:outline-none;
}
input::-ms-reveal,
input::-ms-clear {
display: none;
}
::-webkit-input-placeholder {
line-height: normal;
}
.border {
background-clip: padding-box;
}
media-controller {
@apply w-full;
}
-32
View File
@@ -1,32 +0,0 @@
import { ColumnProvider, LumeProvider } from "@lume/ark";
import { StorageProvider } from "@lume/storage";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider } from "react-i18next";
import { Toaster } from "sonner";
import i18n from "./i18n";
import Router from "./router";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // 10 seconds
},
},
});
export default function App() {
return (
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
<QueryClientProvider client={queryClient}>
<Toaster position="top-center" theme="system" closeButton />
<StorageProvider>
<LumeProvider>
<ColumnProvider>
<Router />
</ColumnProvider>
</LumeProvider>
</StorageProvider>
</QueryClientProvider>
</I18nextProvider>
);
}
-8
View File
@@ -1,8 +0,0 @@
import { createRoot } from "react-dom/client";
import App from "./app";
import "./app.css";
const container = document.getElementById("root");
const root = createRoot(container);
root.render(<App />);
-285
View File
@@ -1,285 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { AppLayout, AuthLayout, HomeLayout, SettingsLayout } from "@lume/ui";
import { fetch } from "@tauri-apps/plugin-http";
import {
RouterProvider,
createBrowserRouter,
defer,
redirect,
} from "react-router-dom";
import { ErrorScreen } from "./routes/error";
export default function Router() {
const ark = useArk();
const storage = useStorage();
const router = createBrowserRouter([
{
element: <AppLayout platform={storage.platform} />,
children: [
{
path: "/",
element: <HomeLayout />,
errorElement: <ErrorScreen />,
loader: async () => {
if (!ark.account) return redirect("auth");
return null;
},
children: [
{
index: true,
async lazy() {
const { HomeScreen } = await import("./routes/home");
return { Component: HomeScreen };
},
},
],
},
{
path: "settings",
element: <SettingsLayout />,
children: [
{
index: true,
async lazy() {
const { GeneralSettingScreen } = await import(
"./routes/settings/general"
);
return { Component: GeneralSettingScreen };
},
},
{
path: "profile",
async lazy() {
const { ProfileSettingScreen } = await import(
"./routes/settings/profile"
);
return { Component: ProfileSettingScreen };
},
},
{
path: "backup",
async lazy() {
const { BackupSettingScreen } = await import(
"./routes/settings/backup"
);
return { Component: BackupSettingScreen };
},
},
{
path: "advanced",
async lazy() {
const { AdvancedSettingScreen } = await import(
"./routes/settings/advanced"
);
return { Component: AdvancedSettingScreen };
},
},
{
path: "nwc",
async lazy() {
const { NWCScreen } = await import("./routes/settings/nwc");
return { Component: NWCScreen };
},
},
{
path: "about",
async lazy() {
const { AboutScreen } = await import("./routes/settings/about");
return { Component: AboutScreen };
},
},
],
},
{
path: "activity",
async lazy() {
const { ActivityScreen } = await import("./routes/activty");
return { Component: ActivityScreen };
},
children: [
{
path: ":id",
async lazy() {
const { ActivityIdScreen } = await import(
"./routes/activty/id"
);
return { Component: ActivityIdScreen };
},
},
],
},
{
path: "relays",
async lazy() {
const { RelaysScreen } = await import("./routes/relays");
return { Component: RelaysScreen };
},
children: [
{
index: true,
async lazy() {
const { RelayGlobalScreen } = await import(
"./routes/relays/global"
);
return { Component: RelayGlobalScreen };
},
},
{
path: "follows",
async lazy() {
const { RelayFollowsScreen } = await import(
"./routes/relays/follows"
);
return { Component: RelayFollowsScreen };
},
},
{
path: ":url",
loader: async ({ request, params }) => {
return defer({
relay: fetch(`https://${params.url}`, {
method: "GET",
headers: {
Accept: "application/nostr+json",
},
signal: request.signal,
}).then((res) => res.json()),
});
},
async lazy() {
const { RelayUrlScreen } = await import("./routes/relays/url");
return { Component: RelayUrlScreen };
},
},
],
},
{
path: "depot",
children: [
{
index: true,
loader: () => {
const depot = storage.checkDepot();
if (!depot) return redirect("/depot/onboarding/");
return null;
},
async lazy() {
const { DepotScreen } = await import("./routes/depot");
return { Component: DepotScreen };
},
},
{
path: "onboarding",
async lazy() {
const { DepotOnboardingScreen } = await import(
"./routes/depot/onboarding"
);
return { Component: DepotOnboardingScreen };
},
},
],
},
],
},
{
path: "auth",
element: <AuthLayout platform={storage.platform} />,
errorElement: <ErrorScreen />,
children: [
{
index: true,
async lazy() {
const { WelcomeScreen } = await import("./routes/auth/welcome");
return { Component: WelcomeScreen };
},
},
{
path: "create",
async lazy() {
const { CreateAccountScreen } = await import(
"./routes/auth/create"
);
return { Component: CreateAccountScreen };
},
},
{
path: "create-keys",
async lazy() {
const { CreateAccountKeys } = await import(
"./routes/auth/create-keys"
);
return { Component: CreateAccountKeys };
},
},
{
path: "create-address",
loader: async () => {
return await ark.getOAuthServices();
},
async lazy() {
const { CreateAccountAddress } = await import(
"./routes/auth/create-address"
);
return { Component: CreateAccountAddress };
},
},
{
path: "login",
async lazy() {
const { LoginScreen } = await import("./routes/auth/login");
return { Component: LoginScreen };
},
},
{
path: "login-key",
async lazy() {
const { LoginWithKey } = await import("./routes/auth/login-key");
return { Component: LoginWithKey };
},
},
{
path: "login-nsecbunker",
async lazy() {
const { LoginWithNsecbunker } = await import(
"./routes/auth/login-nsecbunker"
);
return { Component: LoginWithNsecbunker };
},
},
{
path: "login-oauth",
async lazy() {
const { LoginWithOAuth } = await import(
"./routes/auth/login-oauth"
);
return { Component: LoginWithOAuth };
},
},
{
path: "onboarding",
async lazy() {
const { OnboardingScreen } = await import(
"./routes/auth/onboarding"
);
return { Component: OnboardingScreen };
},
},
],
},
]);
return (
<RouterProvider
router={router}
fallbackElement={
<div className="flex items-center justify-center w-full h-full">
<LoaderIcon className="w-6 h-6 animate-spin" />
</div>
}
future={{ v7_startTransition: true }}
/>
);
}
@@ -1,31 +0,0 @@
import { User } from "@lume/ark";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
export function ActivityRepost({ event }: { event: NDKEvent }) {
const { t } = useTranslation();
return (
<Link
to={`/activity/${event.id}`}
className="block px-5 py-4 border-b border-black/5 dark:border-white/5 hover:bg-black/10 dark:hover:bg-white/10"
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User.Avatar className="size-8 rounded-lg shrink-0" />
<div className="inline-flex items-center gap-1.5">
<User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" />
<p className="shrink-0">{t("activity.repost")}</p>
</div>
</div>
<User.Time
time={event.created_at}
className="text-neutral-500 dark:text-neutral-400"
/>
</User.Root>
</User.Provider>
</Link>
);
}
@@ -1,31 +0,0 @@
import { User } from "@lume/ark";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
export function ActivityText({ event }: { event: NDKEvent }) {
const { t } = useTranslation();
return (
<Link
to={`/activity/${event.id}`}
className="block px-5 py-4 border-b border-black/5 dark:border-white/5 hover:bg-black/10 dark:hover:bg-white/10"
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User.Avatar className="size-8 rounded-lg shrink-0" />
<div className="inline-flex items-center gap-1.5">
<User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" />
<p className="shrink-0">{t("activity.mention")}</p>
</div>
</div>
<User.Time
time={event.created_at}
className="text-neutral-500 dark:text-neutral-400"
/>
</User.Root>
</User.Provider>
</Link>
);
}
@@ -1,35 +0,0 @@
import { User } from "@lume/ark";
import { compactNumber } from "@lume/utils";
import { NDKEvent, zapInvoiceFromEvent } from "@nostr-dev-kit/ndk";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
export function ActivityZap({ event }: { event: NDKEvent }) {
const { t } = useTranslation();
const invoice = zapInvoiceFromEvent(event);
return (
<Link
to={`/activity/${event.id}`}
className="block px-5 py-4 border-b border-black/5 dark:border-white/5 hover:bg-black/10 dark:hover:bg-white/10"
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User.Avatar className="size-8 rounded-lg shrink-0" />
<div className="inline-flex items-center gap-1.5">
<User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" />
<p className="shrink-0">
{t("activity.zap")} {compactNumber.format(invoice.amount)} sats
</p>
</div>
</div>
<User.Time
time={event.created_at}
className="text-neutral-500 dark:text-neutral-400"
/>
</User.Root>
</User.Provider>
</Link>
);
}
@@ -1,117 +0,0 @@
import { useArk } from "@lume/ark";
import { ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ActivityRepost } from "./activityRepost";
import { ActivityText } from "./activityText";
import { ActivityZap } from "./activityZap";
export function ActivityList() {
const ark = useArk();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["activity"],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Zap],
"#p": [ark.account.pubkey],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
initialData: () => {
const queryCacheData = queryClient.getQueryState(["activity"])
?.data as NDKEvent[];
if (queryCacheData) {
return {
pageParams: [undefined, 1],
pages: [queryCacheData],
};
}
},
staleTime: 360 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data],
);
const renderEvenKind = useCallback(
(event: NDKEvent) => {
if (event.pubkey === ark.account.pubkey) return null;
switch (event.kind) {
case NDKKind.Text:
return <ActivityText key={event.id} event={event} />;
case NDKKind.Repost:
return <ActivityRepost key={event.id} event={event} />;
case NDKKind.Zap:
return <ActivityZap key={event.id} event={event} />;
default:
return <ActivityText key={event.id} event={event} />;
}
},
[data],
);
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
{isLoading ? (
<div className="w-full h-full flex flex-col items-center justify-center">
<LoaderIcon className="size-5 animate-spin" />
</div>
) : !allEvents.length ? (
<div className="w-full h-full flex flex-col items-center justify-center">
<p className="mb-2 text-2xl">🎉</p>
<p className="text-center font-medium">{t("activity.empty")}</p>
</div>
) : (
allEvents.map((event) => renderEvenKind(event))
)}
<div className="flex items-center justify-center h-16 px-5">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex items-center justify-center w-full h-12 gap-2 font-medium bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20 rounded-xl focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
{t("global.loadMore")}
</>
)}
</button>
) : null}
</div>
</div>
);
}
@@ -1,40 +0,0 @@
import { Note, useEvent } from "@lume/ark";
export function ActivityRootNote({ eventId }: { eventId: string }) {
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="h-4 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
if (isError) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
Failed to fetch event
</div>
</div>
);
}
return (
<Note.Provider event={data}>
<Note.Root>
<div className="flex items-center justify-between px-3 h-14">
<Note.User className="flex-1 pr-1" />
</div>
<Note.Content className="min-w-0 px-3" />
<div className="flex items-center justify-between px-3 h-14">
<Note.Pin />
<div className="inline-flex items-center gap-10" />
</div>
</Note.Root>
</Note.Provider>
);
}
@@ -1,37 +0,0 @@
import { User } from "@lume/ark";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useTranslation } from "react-i18next";
import { ActivityRootNote } from "./rootNote";
export function ActivitySingleRepost({ event }: { event: NDKEvent }) {
const { t } = useTranslation();
const repostId = event.tags.find((el) => el[0] === "e")[1];
return (
<div className="pb-3 flex flex-col">
<div className="h-14 shrink-0 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3">
<h3 className="text-center font-semibold leading-tight">
{t("activity.boost")}
</h3>
<p className="text-sm text-blue-500 font-medium leading-tight">
{t("activity.boostSubtitle")}
</p>
</div>
<div className="flex-1 min-h-0">
<div className="max-w-xl mx-auto py-6 flex flex-col items-center gap-6">
<User.Provider pubkey={event.pubkey}>
<User.Root>
<User.Avatar className="size-10 shrink-0 rounded-lg object-cover" />
</User.Root>
</User.Provider>
<div className="flex flex-col items-center gap-3">
<div className="h-4 w-px bg-blue-500" />
<h3 className="font-semibold capitalize">{t("activity.repost")}</h3>
<div className="h-4 w-px bg-blue-500" />
</div>
<ActivityRootNote eventId={repostId} />
</div>
</div>
</div>
);
}
@@ -1,62 +0,0 @@
import { Note, useArk } from "@lume/ark";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useTranslation } from "react-i18next";
import { ActivityRootNote } from "./rootNote";
export function ActivitySingleText({ event }: { event: NDKEvent }) {
const ark = useArk();
const thread = ark.getEventThread({
content: event.content,
tags: event.tags,
});
const { t } = useTranslation();
return (
<div className="h-full w-full flex flex-col justify-between">
<div className="h-14 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3">
<h3 className="text-center font-semibold leading-tight">
{t("activity.conversation")}
</h3>
<p className="text-sm text-blue-500 font-medium leading-tight">
{t("activity.conversationSubtitle")}
</p>
</div>
<div className="overflow-y-auto">
<div className="max-w-xl mx-auto py-6">
{thread ? (
<div className="flex flex-col gap-3 mb-1">
{thread.rootEventId ? (
<ActivityRootNote eventId={thread.rootEventId} />
) : null}
{thread.replyEventId ? (
<ActivityRootNote eventId={thread.replyEventId} />
) : null}
</div>
) : null}
<div className="mt-3 flex flex-col gap-3">
<div className="flex items-center gap-3">
<p className="text-teal-500 font-medium">
{t("activity.newReply")}
</p>
<div className="flex-1 h-px bg-teal-300" />
<div className="w-4 shrink-0 h-px bg-teal-300" />
</div>
<Note.Provider event={event}>
<Note.Root>
<div className="flex items-center justify-between px-3 h-14">
<Note.User className="flex-1 pr-1" />
</div>
<Note.Content className="min-w-0 px-3" />
<div className="flex items-center justify-between px-3 h-14">
<Note.Pin />
<div className="inline-flex items-center gap-10" />
</div>
</Note.Root>
</Note.Provider>
</div>
</div>
</div>
</div>
);
}
@@ -1,39 +0,0 @@
import { User } from "@lume/ark";
import { compactNumber } from "@lume/utils";
import { NDKEvent, zapInvoiceFromEvent } from "@nostr-dev-kit/ndk";
import { ActivityRootNote } from "./rootNote";
export function ActivitySingleZap({ event }: { event: NDKEvent }) {
const zapEventId = event.tags.find((el) => el[0] === "e")[1];
const invoice = zapInvoiceFromEvent(event);
return (
<div className="h-full w-full flex flex-col justify-between">
<div className="h-14 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3">
<h3 className="text-center font-semibold leading-tight">
Conversation
</h3>
<p className="text-sm text-blue-500 font-medium leading-tight">
@ Someone has replied to your note
</p>
</div>
<div className="flex-1 min-h-0">
<div className="max-w-xl mx-auto py-6 flex flex-col items-center gap-6">
<User.Provider pubkey={event.pubkey}>
<User.Root>
<User.Avatar className="size-10 shrink-0 rounded-lg object-cover" />
</User.Root>
</User.Provider>
<div className="flex flex-col items-center gap-3">
<div className="h-4 w-px bg-blue-500" />
<h3 className="font-semibold">
Zap you {compactNumber.format(invoice.amount)} sats for
</h3>
<div className="h-4 w-px bg-blue-500" />
</div>
<ActivityRootNote eventId={zapEventId} />
</div>
</div>
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
import { useEvent } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { NDKKind } from "@nostr-dev-kit/ndk";
import { useParams } from "react-router-dom";
import { ActivitySingleRepost } from "./components/singleRepost";
import { ActivitySingleText } from "./components/singleText";
import { ActivitySingleZap } from "./components/singleZap";
export function ActivityIdScreen() {
const { id } = useParams();
const { isLoading, data } = useEvent(id);
if (isLoading || !data) {
return (
<div className="w-full h-full flex items-center justify-center">
<LoaderIcon className="size-5 animate-spin" />
</div>
);
}
if (data.kind === NDKKind.Text) return <ActivitySingleText event={data} />;
if (data.kind === NDKKind.Zap) return <ActivitySingleZap event={data} />;
if (data.kind === NDKKind.Repost)
return <ActivitySingleRepost event={data} />;
return <ActivitySingleText event={data} />;
}
-29
View File
@@ -1,29 +0,0 @@
import { activityUnreadAtom } from "@lume/utils";
import { useSetAtom } from "jotai";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Outlet } from "react-router-dom";
import { ActivityList } from "./components/list";
export function ActivityScreen() {
const { t } = useTranslation();
const setUnreadActivity = useSetAtom(activityUnreadAtom);
useEffect(() => {
setUnreadActivity(0);
}, []);
return (
<div className="flex h-full w-full rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-none dark:ring-1 dark:ring-white/10">
<div className="h-full flex flex-col w-96 shrink-0 rounded-l-xl bg-white/50 backdrop-blur-xl dark:bg-black/50">
<div className="h-14 shrink-0 flex items-center px-5 text-lg font-semibold border-b border-black/10 dark:border-white/10">
{t("activity.title")}
</div>
<ActivityList />
</div>
<div className="flex-1 rounded-r-xl bg-white pb-20 dark:bg-black">
<Outlet />
</div>
</div>
);
}
@@ -1,264 +0,0 @@
import { useArk } from "@lume/ark";
import { CheckIcon, ChevronDownIcon, LoaderIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { onboardingAtom } from "@lume/utils";
import NDK, {
NDKEvent,
NDKKind,
NDKNip46Signer,
NDKPrivateKeySigner,
} from "@nostr-dev-kit/ndk";
import * as Select from "@radix-ui/react-select";
import { UnlistenFn } from "@tauri-apps/api/event";
import { Window } from "@tauri-apps/api/window";
import { useSetAtom } from "jotai";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useLoaderData, useNavigate } from "react-router-dom";
import { toast } from "sonner";
const Item = ({ event }: { event: NDKEvent }) => {
const domain = JSON.parse(event.content).nip05.replace("_@", "");
return (
<Select.Item
value={event.id}
className="relative flex items-center pr-10 leading-none rounded-md select-none text-neutral-100 rounded-mg h-9 pl-7"
>
<Select.ItemText>@{domain}</Select.ItemText>
<Select.ItemIndicator className="absolute left-0 inline-flex items-center justify-center transform h-7">
<CheckIcon className="size-4" />
</Select.ItemIndicator>
</Select.Item>
);
};
export function CreateAccountAddress() {
const ark = useArk();
const storage = useStorage();
const services = useLoaderData() as NDKEvent[];
const setOnboarding = useSetAtom(onboardingAtom);
const navigate = useNavigate();
const [serviceId, setServiceId] = useState(services?.[0]?.id);
const [loading, setIsLoading] = useState(false);
const { t } = useTranslation();
const {
register,
handleSubmit,
formState: { isValid },
} = useForm();
const getDomainName = (id: string) => {
const event = services.find((ev) => ev.id === id);
return JSON.parse(event.content).nip05.replace("_@", "") as string;
};
const onSubmit = async (data: { username: string; email: string }) => {
try {
setIsLoading(true);
const domain = getDomainName(serviceId);
const service = services.find((ev) => ev.id === serviceId);
// generate ndk for nsecbunker
const localSigner = NDKPrivateKeySigner.generate();
const bunker = new NDK({
explicitRelayUrls: [
"wss://relay.nsecbunker.com/",
"wss://nostr.vulpem.com/",
],
});
await bunker.connect(2000);
// generate tmp remote singer for create account
const remoteSigner = new NDKNip46Signer(
bunker,
service.pubkey,
localSigner,
);
// handle auth url request
let unlisten: UnlistenFn;
let authWindow: Window;
let account: string = undefined;
remoteSigner.addListener("authUrl", async (authUrl: string) => {
authWindow = new Window(`auth-${serviceId}`, {
url: authUrl,
title: domain,
titleBarStyle: "overlay",
width: 600,
height: 650,
center: true,
closable: false,
});
unlisten = await authWindow.onCloseRequested(() => {
if (!account) {
setIsLoading(false);
unlisten();
return authWindow.close();
}
});
});
// create new account
account = await remoteSigner.createAccount(
data.username,
domain,
data.email,
);
if (!account) {
unlisten();
setIsLoading(false);
authWindow.close();
return toast.error("Failed to create new account, try again later");
}
unlisten();
authWindow.close();
// add account to storage
await storage.createSetting("nsecbunker", "1");
const newAccount = await storage.createAccount({
pubkey: account,
privkey: localSigner.privateKey,
});
ark.account = newAccount;
// get final signer with newly created account
const finalSigner = new NDKNip46Signer(bunker, account, localSigner);
await finalSigner.blockUntilReady();
// update main ndk instance signer
ark.updateNostrSigner({ signer: finalSigner });
// remove default nsecbunker profile and contact list
// await ark.createEvent({ kind: NDKKind.Metadata, content: "", tags: [] });
await ark.createEvent({ kind: NDKKind.Contacts, content: "", tags: [] });
setIsLoading(false);
setOnboarding({ open: true, newUser: true });
return navigate("/auth/onboarding", { replace: true });
} catch (e) {
setIsLoading(false);
toast.error(String(e));
}
};
return (
<div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">
{t("signupWithProvider.title")}
</h1>
</div>
{!services ? (
<div className="flex items-center justify-center w-full">
<LoaderIcon className="size-5 animate-spin" />
</div>
) : (
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3 mb-0"
>
<div className="flex flex-col gap-6 p-5 bg-neutral-950 rounded-2xl">
<div className="flex flex-col gap-2">
<label
htmlFor="username"
className="text-sm font-semibold uppercase text-neutral-600"
>
{t("signupWithProvider.username")}
</label>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between w-full gap-2 bg-neutral-900 rounded-xl">
<input
type={"text"}
{...register("username", {
required: true,
minLength: 1,
})}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="alice"
className="flex-1 min-w-0 text-xl bg-transparent border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none h-14 ring-0 placeholder:text-neutral-600"
/>
<Select.Root value={serviceId} onValueChange={setServiceId}>
<Select.Trigger className="inline-flex items-center justify-end gap-2 pr-3 text-xl font-semibold text-blue-500 w-max shrink-0">
<Select.Value>@{getDomainName(serviceId)}</Select.Value>
<Select.Icon>
<ChevronDownIcon className="size-5" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content className="rounded-lg border border-white/20 bg-white/10 backdrop-blur-xl">
<Select.Viewport className="p-3">
<Select.Group>
<Select.Label className="mb-2 text-sm font-medium uppercase px-7 text-neutral-600">
{t("signupWithProvider.chooseProvider")}
</Select.Label>
{services.map((service) => (
<Item key={service.id} event={service} />
))}
</Select.Group>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
<span className="text-sm text-neutral-600">
{t("signupWithProvider.usernameFooter")}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex flex-col gap-2">
<label
htmlFor="email"
className="text-sm font-semibold uppercase text-neutral-600"
>
{t("signupWithProvider.email")}
</label>
<input
type={"email"}
{...register("email", { required: false })}
spellCheck={false}
autoCapitalize="none"
autoCorrect="none"
className="px-3 text-xl border-transparent rounded-xl h-14 bg-neutral-900 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
/>
</div>
<span className="text-sm text-neutral-600">
{t("signupWithProvider.emailFooter")}
</span>
</div>
</div>
<div>
<button
type="submit"
disabled={!isValid}
className="inline-flex items-center justify-center w-full text-lg h-12 font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
t("global.continue")
)}
</button>
</div>
</form>
)}
</div>
</div>
);
}
@@ -1,187 +0,0 @@
import { useArk } from "@lume/ark";
import { CheckIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { onboardingAtom } from "@lume/utils";
import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import * as Checkbox from "@radix-ui/react-checkbox";
import { desktopDir } from "@tauri-apps/api/path";
import { save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { useSetAtom } from "jotai";
import { nanoid } from "nanoid";
import { getPublicKey, nip19 } from "nostr-tools";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export function CreateAccountKeys() {
const ark = useArk();
const storage = useStorage();
const setOnboarding = useSetAtom(onboardingAtom);
const navigate = useNavigate();
const [t] = useTranslation();
const [key, setKey] = useState("");
const [loading, setLoading] = useState(false);
const [showKey, setShowKey] = useState(false);
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
const submit = async () => {
try {
setLoading(true);
const privkey = nip19.decode(key).data as string;
const signer = new NDKPrivateKeySigner(privkey);
const pubkey = getPublicKey(privkey);
ark.updateNostrSigner({ signer });
const downloadPath = await desktopDir();
const fileName = `nostr_keys_${nanoid(4)}.txt`;
const filePath = await save({
defaultPath: `${downloadPath}/${fileName}`,
});
if (!filePath) {
return toast.info("You need to save account keys before continue.");
}
await writeTextFile(
filePath,
`Nostr Account\nGenerated by Lume (lume.nu)\n---\nPrivate key: ${key}`,
);
const newAccount = await storage.createAccount({
pubkey: pubkey,
privkey: privkey,
});
ark.account = newAccount;
setLoading(false);
setOnboarding({ open: true, newUser: true });
return navigate("/auth/onboarding", { replace: true });
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
useEffect(() => {
const privkey = NDKPrivateKeySigner.generate().privateKey;
setKey(nip19.nsecEncode(privkey));
}, []);
return (
<div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">
{t("signupWithSelfManage.title")}
</h1>
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
{t("signupWithSelfManage.subtitle")}
</p>
</div>
<div className="flex flex-col gap-6 mb-0">
<div className="flex flex-col gap-6">
<div className="relative">
<input
readOnly
value={key}
type={showKey ? "text" : "password"}
className="pl-3 pr-14 w-full resize-none text-xl border-transparent rounded-xl h-14 bg-neutral-900 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
/>
<button
type="button"
onClick={() => setShowKey((state) => !state)}
className="absolute right-2 top-2 size-10 inline-flex items-center justify-center rounded-lg text-white bg-neutral-800 hover:bg-neutral-700"
>
{showKey ? (
<EyeOnIcon className="size-5" />
) : (
<EyeOffIcon className="size-5" />
)}
</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-900 outline-none"
id="confirm1"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-500"
htmlFor="confirm1"
>
{t("signupWithSelfManage.confirm1")}
</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-900 outline-none"
id="confirm2"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-500"
htmlFor="confirm2"
>
{t("signupWithSelfManage.confirm2")}
</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-900 outline-none"
id="confirm3"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-500"
htmlFor="confirm3"
>
{t("signupWithSelfManage.confirm3")}
</label>
</div>
</div>
</div>
<button
type="button"
onClick={submit}
disabled={!confirm.c1 || !confirm.c2 || !confirm.c3}
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
t("signupWithSelfManage.button")
)}
</button>
</div>
</div>
</div>
);
}
-109
View File
@@ -1,109 +0,0 @@
import { LoaderIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
export function CreateAccountScreen() {
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("/auth/create-keys");
} else {
navigate("/auth/create-address");
}
};
return (
<div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">{t("signup.title")}</h1>
<p className="text-lg font-medium 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 px-4 py-3.5 bg-neutral-900 rounded-xl hover:bg-neutral-800",
method === "self"
? "ring-1 ring-offset-4 ring-offset-black ring-blue-500"
: "",
)}
>
<p className="font-semibold">{t("signup.selfManageMethod")}</p>
<p className="text-sm font-medium text-neutral-500">
{t("signup.selfManageMethodDescription")}
</p>
</button>
<button
type="button"
onClick={() => setMethod("managed")}
className={cn(
"flex flex-col items-start px-4 py-3.5 bg-neutral-900 rounded-xl hover:bg-neutral-800",
method === "managed"
? "ring-1 ring-offset-4 ring-offset-black ring-blue-500"
: "",
)}
>
<div className="inline-flex items-center gap-2">
<p className="font-semibold">{t("signup.providerMethod")}</p>
<span className="text-xs font-medium px-2.5 py-0.5 rounded-full bg-gradient-to-tr from-blue-300 via-sky-500 to-teal-200">
Beta
</span>
</div>
<p className="text-sm font-medium text-neutral-500">
{t("signup.providerMethodDescription")}
</p>
</button>
<div className="flex flex-col gap-3">
<button
type="button"
onClick={next}
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
t("global.continue")
)}
</button>
{method === "managed" ? (
<div className="flex flex-col gap-1 text-sm text-neutral-500">
<p className="text-sm font-semibold text-neutral-300">
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>
);
}
-122
View File
@@ -1,122 +0,0 @@
import { useArk } from "@lume/ark";
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { getPublicKey, nip19 } from "nostr-tools";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export function LoginWithKey() {
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const [showKey, setShowKey] = useState(false);
const [loading, setLoading] = useState(false);
const { t } = useTranslation("loginWithPrivkey.subtitle");
const {
register,
handleSubmit,
setError,
formState: { errors, isValid },
} = useForm();
const onSubmit = async (data: { nsec: string }) => {
try {
if (!data.nsec.startsWith("nsec1"))
return toast.error("You need to enter a private key start with nsec1");
setLoading(true);
const privkey = nip19.decode(data.nsec).data as string;
const pubkey = getPublicKey(privkey);
const account = await storage.createAccount({
pubkey: pubkey,
privkey: privkey,
});
ark.account = account;
return navigate("/auth/onboarding", { replace: true });
} catch (e) {
setLoading(false);
setError("nsec", {
type: "manual",
message: String(e),
});
}
};
return (
<div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">
{t("loginWithPrivkey.title")}
</h1>
<p className="text-lg font-medium whitespace-pre-line leading-snug text-neutral-600 dark:text-neutral-500">
<Trans t={t}>
Lume will put your private key to{" "}
<span className="text-teal-500">
{storage.platform === "macos"
? "Apple Keychain"
: storage.platform === "windows"
? "Credential Manager"
: "Secret Service"}
</span>
. It will be secured by your OS.
</Trans>
</p>
</div>
<div className="flex flex-col gap-6">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4 mb-0"
>
<div className="relative flex flex-col gap-1">
<input
type={showKey ? "text" : "password"}
{...register("nsec", { required: false })}
spellCheck={false}
autoCapitalize="none"
autoCorrect="none"
placeholder="nsec1..."
className="pl-3 pr-11 text-xl border-transparent rounded-xl h-14 bg-neutral-950 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
/>
{errors.nsec && (
<p className="text-sm text-center text-red-600">
{errors.nsec.message as string}
</p>
)}
<button
type="button"
onClick={() => setShowKey((state) => !state)}
className="absolute right-2 top-2 size-10 inline-flex items-center justify-center rounded-lg text-white bg-neutral-900 hover:bg-neutral-800"
>
{showKey ? (
<EyeOnIcon className="size-5" />
) : (
<EyeOffIcon className="size-5" />
)}
</button>
</div>
<button
type="submit"
disabled={!isValid || loading}
className="inline-flex items-center justify-center w-full text-lg h-12 font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
t("global.continue")
)}
</button>
</form>
</div>
</div>
</div>
);
}
@@ -1,114 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export function LoginWithNsecbunker() {
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const {
register,
handleSubmit,
setError,
formState: { errors, isValid },
} = useForm();
const onSubmit = async (data: { npub: string }) => {
try {
if (!data.npub.startsWith("npub1"))
return toast.info("You need to enter a token start with npub1");
if (!data.npub.includes("#"))
return toast.info("Token must include #secret");
setLoading(true);
const bunker = new NDK({
explicitRelayUrls: [
"wss://relay.nsecbunker.com",
"wss://nostr.vulpem.com",
],
});
await bunker.connect(2000);
const pubkey = nip19.decode(data.npub.split("#")[0]).data as string;
const localSigner = NDKPrivateKeySigner.generate();
const remoteSigner = new NDKNip46Signer(bunker, data.npub, localSigner);
await remoteSigner.blockUntilReady();
ark.updateNostrSigner({ signer: remoteSigner });
await storage.createSetting("nsecbunker", "1");
const account = await storage.createAccount({
pubkey: pubkey,
privkey: localSigner.privateKey,
});
ark.account = account;
return navigate("/auth/onboarding", { replace: true });
} catch (e) {
setLoading(false);
setError("npub", {
type: "manual",
message: String(e),
});
}
};
return (
<div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">
{t("loginWithBunker.title")}
</h1>
</div>
<div className="flex flex-col gap-6">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4 mb-0"
>
<div className="relative flex flex-col gap-1">
<input
type="text"
{...register("npub", { required: false })}
spellCheck={false}
autoCapitalize="none"
autoCorrect="none"
placeholder="npub1...#..."
className="px-3 text-xl border-transparent rounded-xl h-14 bg-neutral-950 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
/>
{errors.npub && (
<p className="text-sm text-center text-red-600">
{errors.npub.message as string}
</p>
)}
</div>
<button
type="submit"
disabled={!isValid || loading}
className="inline-flex items-center justify-center w-full text-lg h-12 font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
t("global.continue")
)}
</button>
</form>
</div>
</div>
</div>
);
}
@@ -1,176 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { NIP05 } from "@lume/types";
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { Window } from "@tauri-apps/api/window";
import { fetch } from "@tauri-apps/plugin-http";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/;
export function LoginWithOAuth() {
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const {
register,
handleSubmit,
setError,
formState: { errors, isValid },
} = useForm();
const onSubmit = async (data: { nip05: string }) => {
try {
setLoading(true);
if (!emailRegex.test(data.nip05)) {
setLoading(false);
return toast.error(
"Cannot verify your NIP-05 address, please try again later.",
);
}
const localPath = data.nip05.split("@")[0];
const service = data.nip05.split("@")[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
const req = await fetch(verifyURL, {
method: "GET",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
});
if (!req.ok) {
setLoading(false);
return toast.error(
"Cannot verify your NIP-05 address, please try again later.",
);
}
const res: NIP05 = await req.json();
if (!res.names[localPath.toLowerCase()] || !res.names[localPath]) {
setLoading(false);
return toast.error(
"Cannot verify your NIP-05 address, please try again later.",
);
}
const pubkey =
(res.names[localPath] as string) ||
(res.names[localPath.toLowerCase()] as string);
if (!res.nip46[pubkey]) {
setLoading(false);
return toast.error("Cannot found NIP-46 with this address");
}
const nip46Relays = res.nip46[pubkey] as unknown as string[];
const bunker = new NDK({
explicitRelayUrls: nip46Relays || [
"wss://relay.nsecbunker.com",
"wss://nostr.vulpem.com",
],
});
await bunker.connect(2000);
const localSigner = NDKPrivateKeySigner.generate();
const remoteSigner = new NDKNip46Signer(bunker, pubkey, localSigner);
// handle auth url request
let authWindow: Window;
remoteSigner.addListener("authUrl", (authUrl: string) => {
authWindow = new Window(`auth-${pubkey}`, {
url: authUrl,
title: "Login",
titleBarStyle: "overlay",
width: 415,
height: 600,
center: true,
closable: false,
});
});
const remoteUser = await remoteSigner.blockUntilReady();
if (remoteUser) {
authWindow.close();
ark.updateNostrSigner({ signer: remoteSigner });
await storage.createSetting("nsecbunker", "1");
const account = await storage.createAccount({
pubkey,
privkey: localSigner.privateKey,
});
ark.account = account;
return navigate("/auth/onboarding", { replace: true });
}
} catch (e) {
setLoading(false);
setError("nip05", {
type: "manual",
message: String(e),
});
}
};
return (
<div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">
{t("loginWithAddress.title")}
</h1>
</div>
<div className="flex flex-col gap-6">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4 mb-0"
>
<div className="relative flex flex-col gap-1">
<input
type="email"
{...register("nip05", { required: false })}
spellCheck={false}
autoCapitalize="none"
autoCorrect="none"
placeholder="satoshi@nostr.me"
className="px-3 text-xl border-transparent rounded-xl h-14 bg-neutral-950 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
/>
{errors.nip05 && (
<p className="text-sm text-center text-red-600">
{errors.nip05.message as string}
</p>
)}
</div>
<button
type="submit"
disabled={!isValid || loading}
className="inline-flex items-center justify-center w-full text-lg h-12 font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
t("global.continue")
)}
</button>
</form>
</div>
</div>
</div>
);
}
-55
View File
@@ -1,55 +0,0 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
export function LoginScreen() {
const { t } = useTranslation();
return (
<div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">{t("login.title")}</h1>
</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Link
to="/auth/login-oauth"
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600"
>
{t("login.loginWithAddress")}
</Link>
<Link
to="/auth/login-nsecbunker"
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
>
{t("login.loginWithBunker")}
</Link>
</div>
<div className="flex flex-col gap-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-neutral-900" />
</div>
<div className="relative flex justify-center">
<span className="px-2 font-medium bg-black text-neutral-600">
{t("login.or")}
</span>
</div>
</div>
<div>
<Link
to="/auth/login-key"
className="mb-2 inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
>
{t("login.loginWithPrivkey")}
</Link>
<p className="text-sm text-center text-neutral-500">
{t("login.footer")}
</p>
</div>
</div>
</div>
</div>
</div>
);
}
-198
View File
@@ -1,198 +0,0 @@
import { useArk } from "@lume/ark";
import { InfoIcon, LoaderIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { TranslateRegisterModal } from "@lume/ui";
import * as Switch from "@radix-ui/react-switch";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export function OnboardingScreen() {
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [apiKey, setAPIKey] = useState("");
const [settings, setSettings] = useState({
notification: false,
lowPower: false,
translation: false,
});
const toggleLowPower = async () => {
await storage.createSetting("lowPower", String(+!settings.lowPower));
setSettings((state) => ({ ...state, lowPower: !settings.lowPower }));
};
const toggleTranslation = async () => {
await storage.createSetting("translation", String(+!settings.translation));
setSettings((state) => ({ ...state, translation: !settings.translation }));
};
const toggleNofitication = async () => {
await requestPermission();
setSettings((state) => ({
...state,
notification: !settings.notification,
}));
};
const completeAuth = async () => {
if (settings.translation) {
if (!apiKey.length)
return toast.warning(
"You need to provide Translate API if enable translation",
);
await storage.createSetting("translateApiKey", apiKey);
}
setLoading(true);
// get account contacts
await ark.getUserContacts();
navigate("/", { replace: true });
};
useEffect(() => {
async function loadSettings() {
// get notification permission
const permissionGranted = await isPermissionGranted();
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
// get other settings
const data = await storage.getAllSettings();
for (const item of data) {
if (item.key === "lowPower")
setSettings((prev) => ({
...prev,
lowPower: !!parseInt(item.value),
}));
if (item.key === "translation")
setSettings((prev) => ({
...prev,
translation: !!parseInt(item.value),
}));
}
}
loadSettings();
}, []);
return (
<div className="relative 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 gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">
{t("onboardingSettings.title")}
</h1>
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
{t("onboardingSettings.subtitle")}
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex w-full items-start justify-between gap-4 rounded-xl px-5 py-4 bg-neutral-950">
<Switch.Root
checked={settings.notification}
onClick={() => toggleNofitication()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full outline-none data-[state=checked]:bg-blue-500 bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-neutral-50 transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold text-lg">
{t("onboardingSettings.notification.title")}
</h3>
<p className="text-neutral-500">
{t("onboardingSettings.notification.subtitle")}
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-xl px-5 py-4 bg-neutral-950">
<Switch.Root
checked={settings.lowPower}
onClick={() => toggleLowPower()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full outline-none data-[state=checked]:bg-blue-500 bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-neutral-50 transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold text-lg">
{t("onboardingSettings.lowPower.title")}
</h3>
<p className="text-neutral-500">
{t("onboardingSettings.lowPower.subtitle")}
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-xl px-5 py-4 bg-neutral-950">
<Switch.Root
checked={settings.translation}
onClick={() => toggleTranslation()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full outline-none data-[state=checked]:bg-blue-500 bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-neutral-50 transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold text-lg">
{t("onboardingSettings.translation.title")}
</h3>
<p className="text-neutral-500">
{t("onboardingSettings.translation.subtitle")}
</p>
</div>
</div>
{settings.translation ? (
<div className="flex flex-col w-full items-start justify-between gap-2 rounded-xl px-5 py-4 bg-neutral-950">
<h3 className="font-semibold">Translate API Key</h3>
<input
type="password"
spellCheck={false}
value={apiKey}
onChange={(e) => setAPIKey(e.target.value)}
className="w-full text-xl border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none h-11 rounded-lg ring-0 placeholder:text-neutral-600 bg-neutral-900"
/>
<div className="w-full mt-1">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-neutral-900" />
</div>
<div className="relative flex justify-center">
<span className="px-2 text-sm font-medium bg-neutral-950 text-neutral-600">
Don't have an API key?
</span>
</div>
</div>
<TranslateRegisterModal setAPIKey={setAPIKey} />
</div>
</div>
) : null}
<div className="flex items-center gap-2 rounded-xl px-5 py-3 text-sm bg-blue-950 text-blue-300">
<InfoIcon className="size-8" />
<p>{t("onboardingSettings.footer")}</p>
</div>
<button
type="button"
onClick={completeAuth}
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
t("global.continue")
)}
</button>
</div>
</div>
</div>
);
}
-51
View File
@@ -1,51 +0,0 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
export function WelcomeScreen() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-between w-full h-full">
<div />
<div className="flex flex-col items-center w-full max-w-4xl gap-10 mx-auto">
<div className="flex flex-col items-center text-center">
<img
src="/heading.png"
srcSet="/heading@2x.png 2x"
alt="lume"
className="w-2/3"
/>
<p className="mt-5 text-lg whitespace-pre-line font-medium leading-snug text-neutral-600 dark:text-neutral-500">
{t("welcome.title")}
</p>
</div>
<div className="flex flex-col w-full max-w-xs gap-2 mx-auto">
<Link
to="/auth/create"
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600"
>
{t("welcome.signup")}
</Link>
<Link
to="/auth/login"
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
>
{t("welcome.login")}
</Link>
</div>
</div>
<div className="flex items-center justify-center h-11">
<p className="text-neutral-700">
{t("welcome.footer")}{" "}
<Link
to="https://nostr.com"
target="_blank"
className="text-blue-500"
>
here
</Link>
</p>
</div>
</div>
);
}
@@ -1,72 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon, RunIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { User } from "@lume/ui";
import { NDKKind } from "@nostr-dev-kit/ndk";
import { useState } from "react";
import { toast } from "sonner";
export function DepotContactCard() {
const ark = useArk();
const storage = useStorage();
const [status, setStatus] = useState(false);
const backupContact = async () => {
try {
setStatus(true);
const event = await ark.getEventByFilter({
filter: {
authors: [ark.account.pubkey],
kinds: [NDKKind.Contacts],
},
});
// broadcast to depot
const publish = await event.publish();
if (publish) {
setStatus(false);
toast.success("Backup contact list successfully.");
}
} catch (e) {
setStatus(false);
toast.error(String(e));
}
};
return (
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
<div className="isolate flex -space-x-2">
{ark.account.contacts?.slice(0, 8).map((item) => (
<User key={item} pubkey={item} variant="ministacked" />
))}
{ark.account.contacts?.length > 8 ? (
<div className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-300 text-neutral-900 ring-1 ring-white dark:bg-neutral-700 dark:text-neutral-100 dark:ring-black">
<span className="text-[8px] font-medium">
+{ark.account.contacts?.length - 8}
</span>
</div>
) : null}
</div>
</div>
<div className="inline-flex shrink-0 items-center justify-between">
<div className="text-sm font-medium">Contacts</div>
<button
type="button"
onClick={backupContact}
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
>
{status ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<RunIcon className="size-4" />
)}
Backup
</button>
</div>
</div>
);
}
@@ -1,151 +0,0 @@
import { CancelIcon, PlusIcon, UserAddIcon, UserRemoveIcon } from "@lume/icons";
import { User } from "@lume/ui";
import * as Dialog from "@radix-ui/react-dialog";
import { resolveResource, resolve } from "@tauri-apps/api/path";
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
import { nip19 } from "nostr-tools";
import { useEffect, useState } from "react";
import { parse, stringify } from "smol-toml";
import { toast } from "sonner";
import { VITE_FLATPAK_RESOURCE } from "@lume/utils";
export function DepotMembers() {
const [members, setMembers] = useState<Set<string>>(null);
const [tmpMembers, setTmpMembers] = useState<Array<string>>([]);
const [newMember, setNewMember] = useState("");
const addMember = async () => {
if (!newMember.startsWith("npub1"))
return toast.error("You need to enter a valid npub");
try {
const pubkey = nip19.decode(newMember).data as string;
setTmpMembers((prev) => [...prev, pubkey]);
} catch (e) {
console.error(e);
}
};
const removeMember = (member: string) => {
setTmpMembers((prev) => prev.filter((item) => item !== member));
};
const updateMembers = async () => {
setMembers(new Set(tmpMembers));
const defaultConfig = VITE_FLATPAK_RESOURCE !== null ? await resolve("/",VITE_FLATPAK_RESOURCE) : await resolveResource("resources/config.toml");
const config = await readTextFile(defaultConfig);
const configContent = parse(config);
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
configContent.authorization["pubkey_whitelist"] = [...members];
const newConfig = stringify(configContent);
return await writeTextFile(defaultConfig, newConfig);
};
useEffect(() => {
async function loadConfig() {
const defaultConfig = VITE_FLATPAK_RESOURCE !== null ? await resolve("/",VITE_FLATPAK_RESOURCE) : await resolveResource("resources/config.toml");
const config = await readTextFile(defaultConfig);
const configContent = parse(config);
setTmpMembers(
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
Array.from(configContent.authorization["pubkey_whitelist"]),
);
}
loadConfig();
}, []);
return (
<Dialog.Root>
<div className="flex items-center justify-between rounded-lg bg-neutral-50 p-5 dark:bg-neutral-950">
<div className="flex flex-col items-start">
<h3 className="text-lg font-semibold">Members</h3>
<p className="text-neutral-700 dark:text-neutral-300">
Only allowed users can publish event to your Depot
</p>
</div>
<div className="inline-flex items-center gap-2">
<div className="isolate flex -space-x-2">
{tmpMembers.slice(0, 5).map((item) => (
<User key={item} pubkey={item} variant="stacked" />
))}
{tmpMembers.length > 5 ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-700">
<span className="text-xs font-medium">
+{tmpMembers.length}
</span>
</div>
) : null}
</div>
<Dialog.Trigger className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-lg bg-blue-500 px-3 text-white hover:bg-blue-600">
<UserAddIcon className="size-4" />
Manage
</Dialog.Trigger>
</div>
</div>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/10 backdrop-blur-sm dark:bg-black/10" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-xl overflow-hidden rounded-xl bg-white dark:bg-black">
<div className="inline-flex h-14 w-full shrink-0 items-center justify-between border-b border-neutral-100 px-5 dark:border-neutral-900">
<Dialog.Title className="text-center font-semibold">
Manage member
</Dialog.Title>
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={updateMembers}
className="inline-flex h-8 w-max items-center justify-center rounded-lg bg-blue-500 px-2.5 text-sm font-medium text-white hover:bg-blue-600"
>
Update
</button>
<Dialog.Close className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800">
<CancelIcon className="size-4" />
</Dialog.Close>
</div>
</div>
<div className="pb-3">
<div className="relative mb-2 mt-4 w-full px-5">
<input
type="text"
spellCheck={false}
value={newMember}
onChange={(e) => setNewMember(e.target.value)}
placeholder="npub1..."
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 pl-3 pr-20 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={addMember}
className="absolute right-7 top-1/2 inline-flex h-7 w-max -translate-y-1/2 transform items-center justify-center gap-1 rounded-md bg-neutral-200 px-2.5 text-sm font-medium text-blue-500 hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
>
<PlusIcon className="size-4" />
Add
</button>
</div>
{tmpMembers.map((member) => (
<div
key={member}
className="group flex items-center justify-between px-5 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<User pubkey={member} variant="simple" />
<button
type="button"
onClick={() => removeMember(member)}
className="hidden size-6 items-center justify-center rounded-md bg-neutral-200 group-hover:inline-flex hover:bg-red-200 dark:bg-neutral-800 dark:hover:bg-red-800 dark:hover:text-red-200"
>
<UserRemoveIcon className="size-4 text-red-500" />
</button>
</div>
))}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
@@ -1,61 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon, RunIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { User } from "@lume/ui";
import { NDKKind } from "@nostr-dev-kit/ndk";
import { useState } from "react";
import { toast } from "sonner";
export function DepotProfileCard() {
const ark = useArk();
const storage = useStorage();
const [status, setStatus] = useState(false);
const backupProfile = async () => {
try {
setStatus(true);
const event = await ark.getEventByFilter({
filter: {
authors: [ark.account.pubkey],
kinds: [NDKKind.Metadata],
},
});
// broadcast to depot
const publish = await event.publish();
if (publish) {
setStatus(false);
toast.success("Backup profile successfully.");
}
} catch (e) {
setStatus(false);
toast.error(JSON.stringify(e));
}
};
return (
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
<User pubkey={ark.account.pubkey} variant="simple" />
</div>
<div className="inline-flex shrink-0 items-center justify-between">
<div className="text-sm font-medium">Profile</div>
<button
type="button"
onClick={backupProfile}
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
>
{status ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<RunIcon className="size-4" />
)}
Backup
</button>
</div>
</div>
);
}
@@ -1,75 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon, RunIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { NDKKind } from "@nostr-dev-kit/ndk";
import { useEffect, useState } from "react";
import { toast } from "sonner";
export function DepotRelaysCard() {
const ark = useArk();
const storage = useStorage();
const [status, setStatus] = useState(false);
const [relaySize, setRelaySize] = useState(0);
const backupRelays = async () => {
try {
setStatus(true);
const event = await ark.getEventByFilter({
filter: {
authors: [ark.account.pubkey],
kinds: [NDKKind.RelayList],
},
});
// broadcast to depot
const publish = await event.publish();
if (publish) {
setStatus(false);
toast.success("Backup profile successfully.");
}
} catch (e) {
setStatus(false);
toast.error(JSON.stringify(e));
}
};
useEffect(() => {
async function loadRelays() {
const event = await ark.getEventByFilter({
filter: {
authors: [ark.account.pubkey],
kinds: [NDKKind.RelayList],
},
});
if (event) setRelaySize(event.tags.length);
}
loadRelays();
}, []);
return (
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
<p className="text-lg font-semibold">{relaySize} relays</p>
</div>
<div className="inline-flex shrink-0 items-center justify-between">
<div className="text-sm font-medium">Relay List</div>
<button
type="button"
onClick={backupRelays}
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
>
{status ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<RunIcon className="size-4" />
)}
Backup
</button>
</div>
</div>
);
}
-222
View File
@@ -1,222 +0,0 @@
import { useArk } from "@lume/ark";
import { ChevronDownIcon, DepotIcon, GossipIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { NDKKind } from "@nostr-dev-kit/ndk";
import * as Collapsible from "@radix-ui/react-collapsible";
import { invoke } from "@tauri-apps/api/core";
import { appConfigDir } from "@tauri-apps/api/path";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DepotContactCard } from "./components/contact";
import { DepotMembers } from "./components/members";
import { DepotProfileCard } from "./components/profile";
import { DepotRelaysCard } from "./components/relays";
export function DepotScreen() {
const ark = useArk();
const storage = useStorage();
const [dataPath, setDataPath] = useState("");
const [tunnelUrl, setTunnelUrl] = useState("");
const openFolder = async () => {
await invoke("show_in_folder", {
path: `${dataPath}/nostr.db`,
});
};
const updateRelayList = async () => {
try {
if (tunnelUrl.length < 1)
return toast.info("Please enter a valid relay url");
if (!tunnelUrl.startsWith("ws"))
return toast.info("Please enter a valid relay url");
const relayUrl = new URL(tunnelUrl.replace(/\s/g, ""));
if (!/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/.test(relayUrl.host)) return;
const relayEvent = await ark.getEventByFilter({
filter: {
authors: [ark.account.pubkey],
kinds: [NDKKind.RelayList],
},
});
let publish: { id: string; seens: string[] };
if (!relayEvent) {
publish = await ark.createEvent({
kind: NDKKind.RelayList,
tags: [["r", tunnelUrl, ""]],
});
}
const newTags = relayEvent.tags ?? [];
newTags.push(["r", tunnelUrl, ""]);
publish = await ark.createEvent({
kind: NDKKind.RelayList,
tags: newTags,
});
if (publish) {
await storage.createSetting("tunnel_url", tunnelUrl);
toast.success("Update relay list successfully.");
setTunnelUrl("");
}
} catch (e) {
console.error(e);
toast.error("Error");
}
};
useEffect(() => {
async function loadConfig() {
const appDir = await appConfigDir();
setDataPath(appDir);
}
loadConfig();
}, []);
return (
<div className="flex h-full w-full rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
<div className="h-full w-72 shrink-0 rounded-l-xl bg-white/50 px-8 pt-8 backdrop-blur-xl dark:bg-black/50">
<div className="flex flex-col justify-center gap-4">
<div className="size-16 rounded-xl bg-gradient-to-bl from-teal-300 to-teal-600 p-1">
<div className="relative inline-flex h-full w-full items-center justify-center overflow-hidden rounded-lg bg-gradient-to-bl from-teal-400 to-teal-700 shadow-sm shadow-white/20">
<DepotIcon className="size-8 text-white" />
</div>
</div>
<h1 className="text-xl font-semibold">Depot is running</h1>
</div>
<div className="mt-8 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium">Relay URL</div>
<div className="inline-flex h-10 w-full select-text items-center rounded-lg bg-black/10 px-3 text-sm backdrop-blur-xl dark:bg-white/10">
ws://localhost:6090
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium">Database</div>
<div className="inline-flex h-10 w-full items-center gap-2 truncate rounded-lg bg-black/10 p-1 backdrop-blur-xl dark:bg-white/10">
<p className="shrink-0 pl-2 text-sm">nostr.db (SQLite)</p>
<button
type="button"
onClick={openFolder}
className="inline-flex h-full w-full items-center justify-center rounded-md bg-white text-sm font-medium shadow hover:bg-blue-500 hover:text-white dark:bg-black"
>
Open
</button>
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto rounded-r-xl bg-white pb-20 dark:bg-black">
<div className="mb-5 flex h-12 items-center border-b border-neutral-100 px-5 dark:border-neutral-900">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Actions
</h3>
</div>
<div className="flex flex-col gap-5 px-5">
<Collapsible.Root
defaultOpen
className="flex flex-col overflow-hidden rounded-xl border border-transparent bg-neutral-50 data-[state=open]:border-blue-500 dark:bg-neutral-950"
>
<Collapsible.Trigger className="flex h-20 items-center justify-between px-5 hover:bg-neutral-100 dark:hover:bg-neutral-900">
<div className="flex flex-col items-start">
<h3 className="text-lg font-semibold">Expose</h3>
<p className="text-neutral-700 dark:text-neutral-300">
Make your Depot visible in the Internet, everyone can connect
into it.
</p>
</div>
<ChevronDownIcon className="size-5 shrink-0" />
</Collapsible.Trigger>
<Collapsible.Content>
<div className="flex w-full flex-col gap-4 p-5">
<div>
<p className="mb-1 font-medium">ngrok</p>
<input
readOnly
value="ngrok http --domain=<your_domain> 6090"
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div>
<p className="mb-1 font-medium">Cloudflare Tunnel</p>
<input
readOnly
value="cloudflared tunnel --url localhost:6090"
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div>
<p className="mb-1 font-medium">Local Tunnel</p>
<input
readOnly
value="lt --port 6090"
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="mt-4 border-t border-neutral-100 pt-4 dark:border-neutral-900">
<div className="inline-flex items-center gap-2">
<GossipIcon className="size-5 text-blue-500" />
<h3 className="mb-1 font-semibold">
Support Gossip Model (Recommended)
</h3>
</div>
<div className="w-full max-w-xl">
<p className=" text-balance">
By adding to Relay List, other Nostr Client which support
Gossip Model will automatically connect to your Depot and
improve the discoverability.
</p>
<div className="mt-2 inline-flex w-full items-center gap-2">
<input
type="text"
value={tunnelUrl}
onChange={(e) => setTunnelUrl(e.target.value)}
spellCheck={false}
placeholder="wss://"
className="h-10 flex-1 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={updateRelayList}
className="inline-flex h-10 w-max shrink-0 items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
>
Update
</button>
</div>
</div>
</div>
</div>
</Collapsible.Content>
</Collapsible.Root>
<Collapsible.Root className="flex flex-col overflow-hidden rounded-xl border border-transparent bg-neutral-50 data-[state=open]:border-blue-500 dark:bg-neutral-950">
<Collapsible.Trigger className="flex h-20 items-center justify-between px-5 hover:bg-neutral-100 dark:hover:bg-neutral-900">
<div className="flex flex-col items-start">
<h3 className="text-lg font-semibold">Backup (Recommended)</h3>
<p className="text-neutral-700 dark:text-neutral-300">
Backup all your data to Depot, it always live on your machine.
</p>
</div>
<ChevronDownIcon className="size-5 shrink-0" />
</Collapsible.Trigger>
<Collapsible.Content>
<div className="grid grid-cols-3 gap-4 px-5 py-5">
<DepotProfileCard />
<DepotContactCard />
<DepotRelaysCard />
</div>
</Collapsible.Content>
</Collapsible.Root>
<DepotMembers />
</div>
</div>
</div>
);
}
@@ -1,101 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { delay, VITE_FLATPAK_RESOURCE } from "@lume/utils";
import { resolve, resolveResource } from "@tauri-apps/api/path";
import { useStorage } from "@lume/storage";
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { parse, stringify } from "smol-toml";
import { toast } from "sonner";
export function DepotOnboardingScreen() {
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const launchDepot = async () => {
try {
setLoading(true);
// get default config
const defaultConfig =
VITE_FLATPAK_RESOURCE !== null
? await resolve("/", VITE_FLATPAK_RESOURCE)
: await resolveResource("resources/config.toml");
const config = await readTextFile(defaultConfig);
const parsedConfig = parse(config);
// add current user to whitelist
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
parsedConfig.authorization["pubkey_whitelist"].push(ark.account.pubkey);
// update new config
const newConfig = stringify(parsedConfig);
await writeTextFile(defaultConfig, newConfig);
// launch depot
await storage.launchDepot();
await storage.createSetting("depot", "1");
await delay(2000); // delay 2s to make sure depot is running
// default depot url: ws://localhost:6090
// #TODO: user can custom depot url
const connect = await ark.connectDepot();
if (connect) {
toast.success("Your Depot is successfully launch.");
setLoading(false);
navigate("/depot/");
}
} catch (e) {
toast.error(String(e));
}
};
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-10 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-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
<div className="flex flex-col items-center gap-8">
<div className="text-center">
<h1 className="mb-1 text-3xl font-semibold text-neutral-400 dark:text-neutral-600">
Run your Personal Nostr Relay inside Lume
</h1>
<h2 className="text-4xl font-semibold">Your Relay, Your Control.</h2>
</div>
<div className="rounded-xl bg-blue-100 p-1.5 dark:bg-blue-900">
<button
type="button"
onClick={launchDepot}
className="inline-flex h-11 w-36 transform items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white active:translate-y-1"
>
{loading ? (
<>
<LoaderIcon className="h-5 w-5 animate-spin" />
Launching...
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
>
<path
fillRule="evenodd"
d="M12 2.25a.75.75 0 0 1 .75.75v9a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM6.166 5.106a.75.75 0 0 1 0 1.06 8.25 8.25 0 1 0 11.668 0 .75.75 0 1 1 1.06-1.06c3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788a.75.75 0 0 1 1.06 0Z"
clipRule="evenodd"
/>
</svg>
Launch
</>
)}
</button>
</div>
</div>
</div>
);
}
-159
View File
@@ -1,159 +0,0 @@
import { useArk } from "@lume/ark";
import { useStorage } from "@lume/storage";
import { downloadDir } from "@tauri-apps/api/path";
import { message, save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { relaunch } from "@tauri-apps/plugin-process";
import { useRouteError } from "react-router-dom";
interface RouteError {
statusText: string;
message: string;
}
export function ErrorScreen() {
const ark = useArk();
const storage = useStorage();
const error = useRouteError() as RouteError;
const restart = async () => {
await relaunch();
};
const download = async () => {
try {
const downloadPath = await downloadDir();
const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
const filePath = await save({
defaultPath: `${downloadPath}/${fileName}`,
});
const nsec = await storage.loadPrivkey(ark.account.pubkey);
if (filePath) {
if (nsec) {
await writeTextFile(
filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${ark.account.id}\nPrivate key: ${nsec}`,
);
} else {
await writeTextFile(
filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${ark.account.id}`,
);
}
} // else { user cancel action }
} catch (e) {
await message(e, {
title: "Cannot download account keys",
type: "error",
});
}
};
return (
<div
data-tauri-drag-region
className="relative flex h-screen w-screen items-center justify-center bg-blue-500 overflow-hidden rounded-xl"
>
<div className="flex w-full max-w-2xl flex-col items-start gap-8">
<div className="flex flex-col">
<h1 className="mb-3 text-4xl font-semibold text-blue-400">
Sorry, an unexpected error has occurred.
</h1>
<h3 className="text-3xl font-semibold leading-snug text-white">
Don&apos;t panic, your account is safe.
<br />
Here are what things you can do:
</h3>
</div>
<div className="flex w-full flex-col gap-3">
<div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
<div className="text-xl font-semibold text-white">
1. Try to close and re-open the app
</div>
<button
type="button"
onClick={() => restart()}
className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
>
Restart
</button>
</div>
<div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
<div className="text-xl font-semibold text-white">
2. Backup Nostr account
</div>
<button
type="button"
onClick={() => download()}
className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
>
Download
</button>
</div>
<div className="rounded-xl bg-blue-700 px-3 py-4">
<div className="flex w-full flex-col gap-2">
<div className="flex w-full items-center justify-between">
<div className="text-xl font-semibold text-white">
3. Report this issue to Lume
</div>
<a
href="https://github.com/lumehq/lume/issues/new"
target="_blank"
rel="noreferrer"
className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
>
Report
</a>
</div>
<div className="inline-flex h-16 items-center justify-center overflow-y-auto rounded-lg border border-dashed border-red-300 bg-blue-800 px-5">
<p className="select-text break-all text-red-400">
{error.statusText || error.message}
</p>
</div>
</div>
</div>
<div className="rounded-xl bg-blue-700 px-3 py-4">
<div className="flex w-full flex-col gap-1.5">
<div className="text-xl font-semibold text-white">
4. Use another Nostr client
</div>
<div className="select-text text-lg font-medium text-blue-300">
<p>
While waiting for Lume release the bug fixes, you always can
use other Nostr clients with your account:
</p>
<div className="mt-2 flex flex-col gap-1 text-white">
<a
className="hover:!underline"
href="https://snort.social/"
target="_blank"
rel="noreferrer"
>
snort.social
</a>
<a
className="hover:!underline"
href="https://nostter.app/"
target="_blank"
rel="noreferrer"
>
nostter
</a>
<a
className="hover:!underline"
href="https://nostrudel.ninja/"
target="_blank"
rel="noreferrer"
>
nostrudel.ninja
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
-201
View File
@@ -1,201 +0,0 @@
import { Antenas } from "@columns/antenas";
import { Default } from "@columns/default";
import { ForYou } from "@columns/foryou";
import { Global } from "@columns/global";
import { Group } from "@columns/group";
import { Hashtag } from "@columns/hashtag";
import { Thread } from "@columns/thread";
import { Timeline } from "@columns/timeline";
import { TrendingNotes } from "@columns/trending-notes";
import { User } from "@columns/user";
import { Waifu } from "@columns/waifu";
import { useColumnContext } from "@lume/ark";
import {
ArrowLeftIcon,
ArrowRightIcon,
PlusIcon,
PlusSquareIcon,
} from "@lume/icons";
import { IColumn } from "@lume/types";
import { TutorialModal } from "@lume/ui/src/tutorial/modal";
import { COL_TYPES } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { VList } from "virtua";
export function HomeScreen() {
const { t } = useTranslation();
const { columns, vlistRef, addColumn } = useColumnContext();
const [selectedIndex, setSelectedIndex] = useState(-1);
const renderItem = (column: IColumn) => {
switch (column.kind) {
case COL_TYPES.default:
return <Default key={column.id} column={column} />;
case COL_TYPES.newsfeed:
return <Timeline key={column.id} column={column} />;
case COL_TYPES.foryou:
return <ForYou key={column.id} column={column} />;
case COL_TYPES.thread:
return <Thread key={column.id} column={column} />;
case COL_TYPES.user:
return <User key={column.id} column={column} />;
case COL_TYPES.hashtag:
return <Hashtag key={column.id} column={column} />;
case COL_TYPES.group:
return <Group key={column.id} column={column} />;
case COL_TYPES.antenas:
return <Antenas key={column.id} column={column} />;
case COL_TYPES.global:
return <Global key={column.id} column={column} />;
case COL_TYPES.trendingNotes:
return <TrendingNotes key={column.id} column={column} />;
case COL_TYPES.waifu:
return <Waifu key={column.id} column={column} />;
default:
return <Default key={column.id} column={column} />;
}
};
return (
<div className="relative w-full h-full">
<VList
ref={vlistRef}
className="h-full w-full flex-nowrap overflow-x-auto !overflow-y-hidden scrollbar-none focus:outline-none"
itemSize={420}
tabIndex={0}
horizontal
onKeyDown={(e) => {
if (!vlistRef.current) return;
switch (e.code) {
case "ArrowUp":
case "ArrowLeft": {
e.preventDefault();
const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex);
vlistRef.current.scrollToIndex(prevIndex, {
align: "center",
smooth: true,
});
break;
}
case "ArrowDown":
case "ArrowRight": {
e.preventDefault();
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
setSelectedIndex(nextIndex);
vlistRef.current.scrollToIndex(nextIndex, {
align: "center",
smooth: true,
});
break;
}
default:
break;
}
}}
>
{columns.map((column) => renderItem(column))}
<div className="w-[420px] h-full flex items-center justify-center">
<button
type="button"
onClick={async () =>
await addColumn({
kind: COL_TYPES.default,
title: "",
content: "",
})
}
className="size-16 inline-flex items-center justify-center hover:bg-neutral-100 dark:hover:bg-neutral-900 rounded-2xl"
>
<PlusIcon className="size-6" />
</button>
</div>
</VList>
<Tooltip.Provider>
<div className="absolute bottom-3 right-3">
<div className="flex items-center gap-1 p-1 bg-black/50 dark:bg-white/30 backdrop-blur-xl rounded-xl shadow-toolbar">
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => {
const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex);
vlistRef.current.scrollToIndex(prevIndex, {
align: "center",
smooth: true,
});
}}
className="inline-flex items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-black/30 size-10"
>
<ArrowLeftIcon className="size-5" />
</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("global.moveLeft")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => {
const nextIndex = Math.min(
selectedIndex + 1,
columns.length - 1,
);
setSelectedIndex(nextIndex);
vlistRef.current.scrollToIndex(nextIndex, {
align: "center",
smooth: true,
});
}}
className="inline-flex items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-black/30 size-10"
>
<ArrowRightIcon className="size-5" />
</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("global.moveRight")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={async () =>
await addColumn({
kind: COL_TYPES.default,
title: "",
content: "",
})
}
className="inline-flex items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-black/30 size-10"
>
<PlusSquareIcon className="size-5" />
</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("global.newColumn")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<div className="w-px h-6 bg-white/10" />
<TutorialModal />
</div>
</div>
</Tooltip.Provider>
</div>
);
}
@@ -1,89 +0,0 @@
import { NoteSkeleton, RepostNote, TextNote, useArk } from "@lume/ark";
import { ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { VList } from "virtua";
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
const ark = useArk();
const { t } = useTranslation();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["relay-events", relayUrl],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const url = `wss://${relayUrl}`;
const events = await ark.getRelayEvents({
relayUrl: url,
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
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 = useCallback(
(event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote key={event.id} event={event} className="mt-3" />;
case NDKKind.Repost:
return <RepostNote key={event.id} event={event} className="mt-3" />;
default:
return <TextNote key={event.id} event={event} className="mt-3" />;
}
},
[data],
);
return (
<VList className="mx-auto h-full w-full max-w-[500px] px-3 scrollbar-none">
{status === "pending" ? (
<NoteSkeleton />
) : (
data.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
{t("global.loading")}
</>
)}
</button>
) : null}
</div>
</VList>
);
}
@@ -1,54 +0,0 @@
import { useRelaylist } from "@lume/ark";
import { PlusIcon } from "@lume/icons";
import { normalizeRelayUrl } from "nostr-fetch";
import { useState } from "react";
import { toast } from "sonner";
export function RelayForm() {
const { connectRelay } = useRelaylist();
const [relay, setRelay] = useState<{
url: WebSocket["url"];
purpose: "read" | "write" | undefined;
}>({ url: "", purpose: undefined });
const create = () => {
if (relay.url.length < 1) return toast.info("Please enter relay url");
try {
const relayUrl = new URL(relay.url.replace(/\s/g, ""));
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
connectRelay.mutate(normalizeRelayUrl(relay.url));
setRelay({ url: "", purpose: undefined });
} else {
return toast.error(
"URL is invalid, a relay must use websocket protocol (start with wss:// or ws://). Please check again",
);
}
} catch {
return toast.error("Relay URL is not valid. Please check again");
}
};
return (
<div className="flex gap-2">
<input
className="h-11 w-full rounded-lg border-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 bg-white/50 dark:bg-black/50 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
placeholder="wss://"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
value={relay.url}
onChange={(e) => setRelay((prev) => ({ ...prev, url: e.target.value }))}
/>
<button
type="button"
onClick={() => create()}
className="inline-flex size-11 shrink-0 items-center justify-center rounded-lg bg-blue-500 text-white hover:bg-blue-600"
>
<PlusIcon className="size-5" />
</button>
</div>
);
}
@@ -1,57 +0,0 @@
import { User, useRelaylist } from "@lume/ark";
import { PlusIcon, SearchIcon } from "@lume/icons";
import { normalizeRelayUrl } from "nostr-fetch";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
export function RelayItem({ url, users }: { url: string; users?: string[] }) {
const domain = new URL(url).hostname;
const { t } = useTranslation();
const { connectRelay } = useRelaylist();
return (
<div className="flex h-14 w-full items-center justify-between border-b border-neutral-100 px-5 dark:border-neutral-950">
<div className="inline-flex items-center gap-2">
<span className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">
{t("global.relay")}:{" "}
</span>
<span className="max-w-[200px] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{url}
</span>
</div>
<div className="inline-flex items-center gap-2">
{users ? (
<div className="isolate flex -space-x-2 mr-4">
{users.slice(0, 4).map((item) => (
<User.Provider pubkey={item}>
<User.Root>
<User.Avatar className="size-8 inline-block rounded-full ring-1 ring-neutral-100 dark:ring-neutral-900" />
</User.Root>
</User.Provider>
))}
{users.length > 4 ? (
<div className="inline-flex size-8 items-center justify-center rounded-full bg-neutral-100 text-neutral-900 ring-1 ring-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:ring-neutral-800">
<span className="text-xs font-medium">+{users.length - 4}</span>
</div>
) : null}
</div>
) : null}
<Link
to={`/relays/${domain}/`}
className="inline-flex h-8 items-center justify-center gap-2 rounded-lg bg-neutral-100 px-2 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
<SearchIcon className="size-4" />
{t("global.inspect")}
</Link>
<button
type="button"
onClick={() => connectRelay.mutate(normalizeRelayUrl(url))}
className="inline-flex size-8 items-center justify-center rounded-lg bg-blue-100 text-blue-500 hover:bg-blue-200 dark:bg-blue-900 hover:dark:bg-blue-800"
>
<PlusIcon className="size-5" />
</button>
</div>
</div>
);
}
@@ -1,111 +0,0 @@
import { useArk, useRelaylist } from "@lume/ark";
import { CancelIcon, LoaderIcon, RefreshIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { RelayForm } from "./relayForm";
export function RelaySidebar({ className }: { className?: string }) {
const ark = useArk();
const { t } = useTranslation();
const { removeRelay } = useRelaylist();
const { status, data, isRefetching, refetch } = useQuery({
queryKey: ["relay-personal"],
queryFn: async () => {
const event = await ark.getEventByFilter({
filter: {
kinds: [NDKKind.RelayList],
authors: [ark.account.pubkey],
},
cache: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
if (!event) return [];
return event.tags.filter((tag) => tag[0] === "r");
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
const currentRelays = new Set(
ark.ndk.pool.connectedRelays().map((item) => item.url),
);
return (
<div
className={cn(
"rounded-l-xl bg-white/50 backdrop-blur-xl dark:bg-black/50",
className,
)}
>
<div className="inline-flex items-center justify-between w-full h-14 px-3 border-b border-black/10 dark:border-white/10">
<h3 className="font-semibold">{t("relays.sidebar.title")}</h3>
<button
type="button"
onClick={() => refetch()}
className="inline-flex items-center justify-center w-6 h-6 rounded-md shrink-0 hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<RefreshIcon
className={cn("size-4", isRefetching ? "animate-spin" : "")}
/>
</button>
</div>
<div className="flex flex-col gap-2 px-3 mt-3">
{status === "pending" ? (
<div className="flex items-center justify-center w-full h-20 rounded-lg bg-black/10 dark:bg-white/10">
<LoaderIcon className="size-5 animate-spin" />
</div>
) : !data.length ? (
<div className="flex items-center justify-center w-full h-20 rounded-lg bg-black/10 dark:bg-white/10">
<p className="text-sm font-medium">{t("relays.sidebar.empty")}</p>
</div>
) : (
data.map((item) => (
<div
key={item[1]}
className="flex items-center justify-between px-3 rounded-lg group h-11 bg-white/50 dark:bg-black/50"
>
<div className="inline-flex items-baseline gap-2">
{currentRelays.has(item[1]) ? (
<span className="relative flex w-2 h-2">
<span className="absolute inline-flex w-full h-full bg-green-400 rounded-full opacity-75 animate-ping" />
<span className="relative inline-flex w-2 h-2 bg-teal-500 rounded-full" />
</span>
) : (
<span className="relative flex w-2 h-2">
<span className="absolute inline-flex w-full h-full bg-red-400 rounded-full opacity-75 animate-ping" />
<span className="relative inline-flex w-2 h-2 bg-red-500 rounded-full" />
</span>
)}
<p className="max-w-[20rem] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{item[1]
.replace("wss://", "")
.replace("ws://", "")
.replace("/", "")}
</p>
</div>
<div className="inline-flex items-center gap-2">
{item[2]?.length ? (
<div className="inline-flex items-center justify-center h-6 px-2 text-xs font-medium capitalize rounded w-max bg-neutral-200 dark:bg-neutral-800">
{item[2]}
</div>
) : null}
<button
type="button"
onClick={() => removeRelay.mutate(item[1])}
className="items-center justify-center hidden size-6 rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<CancelIcon className="size-4 text-neutral-900 dark:text-neutral-100" />
</button>
</div>
</div>
))
)}
<RelayForm />
</div>
</div>
);
}
@@ -1,48 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { useQuery } from "@tanstack/react-query";
import { VList } from "virtua";
import { RelayItem } from "./components/relayItem";
export function RelayFollowsScreen() {
const ark = useArk();
const {
isLoading,
isError,
data: relays,
} = useQuery({
queryKey: ["relay-follows"],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const data = await ark.getAllRelaysFromContacts({ signal });
if (!data) throw new Error("Failed to get relay list from contacts");
return data;
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="size-5 animate-spin" />
</div>
);
}
if (isError || !relays) {
return (
<div className="flex h-full w-full items-center justify-center">
<p>Error</p>
</div>
);
}
return (
<VList itemSize={49}>
{[...relays].map(([key, value]) => (
<RelayItem key={key} url={key} users={value} />
))}
</VList>
);
}
-35
View File
@@ -1,35 +0,0 @@
import { LoaderIcon } from "@lume/icons";
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/plugin-http";
import { VList } from "virtua";
import { RelayItem } from "./components/relayItem";
export function RelayGlobalScreen() {
const { isLoading, data: relays } = useQuery({
queryKey: ["relay-global"],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch("https://api.nostr.watch/v1/online", { signal });
if (!res.ok) throw new Error("Failed to get online relays");
return (await res.json()) as string[];
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="size-5 animate-spin" />
</div>
);
}
return (
<VList itemSize={49}>
{relays.map((item: string) => (
<RelayItem key={item} url={item} />
))}
</VList>
);
}
-48
View File
@@ -1,48 +0,0 @@
import { cn } from "@lume/utils";
import { useTranslation } from "react-i18next";
import { NavLink, Outlet } from "react-router-dom";
import { RelaySidebar } from "./components/sidebar";
export function RelaysScreen() {
const { t } = useTranslation();
return (
<div className="grid h-full w-full lg:grid-cols-4 xl:grid-cols-5 rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-none dark:ring-1 dark:ring-white/10">
<RelaySidebar className="col-span-1" />
<div className="col-span-3 xl:col-span-4 flex flex-col rounded-r-xl bg-white dark:bg-black">
<div className="h-14 shrink-0 flex px-5 items-center gap-6 border-b border-neutral-100 dark:border-neutral-950">
<NavLink
end
to={"/relays/"}
className={({ isActive }) =>
cn(
"h-9 w-24 rounded-lg inline-flex items-center justify-center font-medium",
isActive
? "bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-950 dark:hover:bg-neutral-900"
: "",
)
}
>
{t("relays.global")}
</NavLink>
<NavLink
to={"/relays/follows/"}
className={({ isActive }) =>
cn(
"h-9 w-24 rounded-lg inline-flex items-center justify-center font-medium",
isActive
? "bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-950 dark:hover:bg-neutral-900"
: "",
)
}
>
{t("relays.follows")}
</NavLink>
</div>
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<Outlet />
</div>
</div>
</div>
);
}
-160
View File
@@ -1,160 +0,0 @@
import { LoaderIcon } from "@lume/icons";
import { NIP11 } from "@lume/types";
import { User } from "@lume/ui";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Await, useLoaderData, useParams } from "react-router-dom";
import { RelayEventList } from "./components/relayEventList";
export function RelayUrlScreen() {
const { t } = useTranslation();
const { url } = useParams();
const data: { relay?: { [key: string]: string } } = useLoaderData();
const getSoftwareName = (url: string) => {
const filename = url.substring(url.lastIndexOf("/") + 1);
return filename.replace(".git", "");
};
const titleCase = (s: string) => {
return s
.replace(/^[-_]*(.)/, (_, c) => c.toUpperCase())
.replace(/[-_]+(.)/g, (_, c) => ` ${c.toUpperCase()}`);
};
return (
<div className="grid h-full w-full grid-cols-3">
<div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900">
<RelayEventList relayUrl={url} />
</div>
<div className="col-span-1 px-3 py-3">
<Suspense
fallback={
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900 dark:text-neutral-100">
<LoaderIcon className="h-4 w-4 animate-spin" />
{t("global.loading")}
</div>
}
>
<Await
resolve={data.relay}
errorElement={
<div className="text-sm font-medium">
<p>{t("relays.relayView.empty")}</p>
</div>
}
>
{(resolvedRelay: NIP11) => (
<div className="flex flex-col gap-5">
<div>
<h3 className="font-semibold">{resolvedRelay.name}</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-500">
{resolvedRelay.description}
</p>
</div>
{resolvedRelay.pubkey ? (
<div className="flex flex-col gap-1">
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{t("relays.relayView.owner")}:
</h5>
<div className="w-full rounded-lg bg-neutral-100 px-2 py-2 dark:bg-neutral-900">
<User pubkey={resolvedRelay.pubkey} variant="simple" />
</div>
</div>
) : null}
{resolvedRelay.contact ? (
<div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{t("relays.relayView.contact")}:
</h5>
<a
href={`mailto:${resolvedRelay.contact}`}
target="_blank"
className="truncate underline after:content-['_↗'] hover:text-blue-500"
rel="noreferrer"
>
{resolvedRelay.contact}
</a>
</div>
) : null}
<div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{t("relays.relayView.software")}:
</h5>
<a
href={resolvedRelay.software}
target="_blank"
rel="noreferrer"
className="underline after:content-['_↗'] hover:text-blue-500"
>
{`${getSoftwareName(resolvedRelay.software)} - ${
resolvedRelay.version
}`}
</a>
</div>
<div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{t("relays.relayView.nips")}:
</h5>
<div className="mt-2 grid grid-cols-7 gap-2">
{resolvedRelay.supported_nips.map((item) => (
<a
key={item}
href={`https://nips.be/${item}`}
target="_blank"
rel="noreferrer"
className="inline-flex aspect-square h-auto w-full items-center justify-center rounded bg-neutral-100 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
>
{item}
</a>
))}
</div>
</div>
{resolvedRelay.limitation ? (
<div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{t("relays.relayView.limit")}
</h5>
<div className="flex flex-col gap-2 divide-y divide-white/5">
{Object.keys(resolvedRelay.limitation).map((key) => {
return (
<div
key={key}
className="flex items-baseline justify-between pt-2"
>
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{titleCase(key)}:
</p>
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
{resolvedRelay.limitation[key].toString()}
</p>
</div>
);
})}
</div>
</div>
) : null}
{resolvedRelay.payments_url ? (
<div className="flex flex-col gap-1">
<a
href={resolvedRelay.payments_url}
target="_blank"
rel="noreferrer"
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium hover:bg-blue-600"
>
{t("relays.relayView.payment")}
</a>
<span className="text-center text-xs text-neutral-600 dark:text-neutral-400">
{t("relays.relayView.paymentNote")}
</span>
</div>
) : null}
</div>
)}
</Await>
</Suspense>
</div>
</div>
);
}
@@ -1,75 +0,0 @@
import { getVersion } from "@tauri-apps/api/app";
import { relaunch } from "@tauri-apps/plugin-process";
import { Update, check } from "@tauri-apps/plugin-updater";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { toast } from "sonner";
export function AboutScreen() {
const [t] = useTranslation();
const [version, setVersion] = useState("");
const [newUpdate, setNewUpdate] = useState<Update>(null);
const checkUpdate = async () => {
const update = await check();
if (!update) toast.info("There is no update available");
setNewUpdate(update);
};
const installUpdate = async () => {
await newUpdate.downloadAndInstall();
await relaunch();
};
useEffect(() => {
async function loadVersion() {
const appVersion = await getVersion();
setVersion(appVersion);
}
loadVersion();
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex flex-col items-center">
<h1 className="leading-tight text-xl font-semibold">Lume</h1>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t("settings.about.version")} {version}
</p>
</div>
<div className="mx-auto mt-4 flex w-full max-w-xs flex-col gap-2">
{!newUpdate ? (
<button
type="button"
onClick={() => checkUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
>
{t("settings.about.checkUpdate")}
</button>
) : (
<button
type="button"
onClick={() => installUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
>
{t("settings.about.installUpdate")} {newUpdate.version}
</button>
)}
<Link
to="https://lume.nu"
className="inline-flex h-9 w-full 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"
>
Website
</Link>
<Link
to="https://github.com/lumehq/lume/issues"
className="inline-flex h-9 w-full 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"
>
Report a issue
</Link>
</div>
</div>
);
}
@@ -1,35 +0,0 @@
import { useStorage } from "@lume/storage";
import { useTranslation } from "react-i18next";
export function AdvancedSettingScreen() {
const storage = useStorage();
const { t } = useTranslation();
const clearCache = async () => {
await storage.clearCache();
};
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex flex-col gap-6">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">
{t("settings.advanced.cache.title")}
</div>
<div className="text-sm">
{t("settings.advanced.cache.subtitle")}
</div>
</div>
<button
type="button"
onClick={() => clearCache()}
className="h-8 w-max rounded-lg px-3 text-sm font-semibold text-blue-500 bg-blue-100 hover:bg-blue-200"
>
{t("settings.advanced.cache.button")}
</button>
</div>
</div>
</div>
);
}
@@ -1,64 +0,0 @@
import { useArk } from "@lume/ark";
import { EyeOffIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { nip19 } from "nostr-tools";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export function BackupSettingScreen() {
const ark = useArk();
const storage = useStorage();
const [t] = useTranslation();
const [privkey, setPrivkey] = useState(null);
const [showPassword, setShowPassword] = useState(false);
const removePrivkey = async () => {
await storage.removePrivkey(ark.account.pubkey);
};
useEffect(() => {
async function loadPrivkey() {
const key = await storage.loadPrivkey(ark.account.pubkey);
if (key) setPrivkey(key);
}
loadPrivkey();
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div>
{privkey ? (
<div>
<div className="mb-2 text-sm font-semibold">
{t("settings.backup.privkey.title")}
</div>
<div className="relative">
<input
readOnly
type={showPassword ? "text" : "password"}
value={nip19.nsecEncode(privkey)}
className="relative h-11 w-full resize-none rounded-lg border-none bg-neutral-200 py-1 pl-3 pr-11 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-1.5 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 transform items-center justify-center rounded-lg bg-neutral-50 dark:bg-neutral-950"
>
<EyeOffIcon className="h-4 w-4" />
</button>
</div>
<button
type="button"
onClick={() => removePrivkey()}
className="mt-2 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-red-200 dark:bg-red-800 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:hover:text-white"
>
{t("settings.backup.privkey.button")}
</button>
</div>
) : null}
</div>
</div>
);
}
@@ -1,46 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export function AvatarUpload({ setPicture }) {
const ark = useArk();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const upload = async () => {
try {
setLoading(true);
// upload image to nostr.build server
// #TODO: support multiple server
const image = await ark.upload({ fileExts: [] });
if (!image)
toast.error("Failed to upload image, please try again later.");
setPicture(image);
setLoading(false);
} catch (e) {
setLoading(false);
toast.error("Failed to upload image, please try again later.");
}
};
return (
<button
type="button"
onClick={upload}
disabled={loading}
className="inline-flex items-center justify-center w-36 font-medium rounded-lg h-8 bg-blue-500 hover:bg-blue-600 text-white disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
t("user.avatarButton")
)}
</button>
);
}
@@ -1,46 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export function CoverUpload({ setBanner }) {
const ark = useArk();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const upload = async () => {
try {
setLoading(true);
// upload image to nostr.build server
// #TODO: support multiple server
const image = await ark.upload({ fileExts: [] });
if (!image)
toast.error("Failed to upload image, please try again later.");
setBanner(image);
setLoading(false);
} catch (e) {
setLoading(false);
toast.error("Failed to upload image, please try again later.");
}
};
return (
<button
type="button"
onClick={upload}
disabled={loading}
className="inline-flex items-center justify-center w-32 font-medium rounded-lg h-8 bg-blue-500 hover:bg-blue-600 text-white disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
t("user.coverButton")
)}
</button>
);
}
@@ -1,320 +0,0 @@
import { DarkIcon, LightIcon, SystemModeIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { cn } from "@lume/utils";
import * as Switch from "@radix-ui/react-switch";
import { invoke } from "@tauri-apps/api/core";
import { getCurrent } from "@tauri-apps/api/window";
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export function GeneralSettingScreen() {
const storage = useStorage();
const [t] = useTranslation();
const [apiKey, setAPIKey] = useState("");
const [settings, setSettings] = useState({
...storage.settings,
notification: false,
autolaunch: false,
appearance: "system",
});
const changeTheme = async (theme: "light" | "dark" | "auto") => {
await invoke("plugin:theme|set_theme", { theme });
// update state
setSettings((prev) => ({ ...prev, appearance: theme }));
};
const toggleLowPower = async () => {
await storage.createSetting("lowPower", String(+!settings.lowPower));
setSettings((state) => ({ ...state, lowPower: !settings.lowPower }));
};
const toggleAutolaunch = async () => {
if (!settings.autolaunch) {
await enable();
// update state
setSettings((prev) => ({ ...prev, autolaunch: true }));
} else {
await disable();
// update state
setSettings((prev) => ({ ...prev, autolaunch: false }));
}
};
const toggleMedia = async () => {
await storage.createSetting("media", String(+!settings.media));
storage.settings.media = !settings.media;
// update state
setSettings((prev) => ({ ...prev, media: !settings.media }));
};
const toggleHashtag = async () => {
await storage.createSetting("hashtag", String(+!settings.hashtag));
storage.settings.hashtag = !settings.hashtag;
// update state
setSettings((prev) => ({ ...prev, hashtag: !settings.hashtag }));
};
const toggleAutoupdate = async () => {
await storage.createSetting("autoupdate", String(+!settings.autoupdate));
storage.settings.autoupdate = !settings.autoupdate;
// update state
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
};
const toggleNofitication = async () => {
if (settings.notification) return;
await requestPermission();
// update state
setSettings((prev) => ({ ...prev, notification: !settings.notification }));
};
const toggleTranslation = async () => {
await storage.createSetting("translation", String(+!settings.translation));
storage.settings.translation = !settings.translation;
// update state
setSettings((prev) => ({ ...prev, translation: !settings.translation }));
};
const saveApi = async () => {
await storage.createSetting("translateApiKey", apiKey);
};
useEffect(() => {
async function loadSettings() {
const theme = await getCurrent().theme();
setSettings((prev) => ({ ...prev, appearance: theme }));
const autostart = await isEnabled();
setSettings((prev) => ({ ...prev, autolaunch: autostart }));
const permissionGranted = await isPermissionGranted();
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
}
loadSettings();
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex flex-col gap-6">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold">
{t("settings.general.update.title")}
</div>
<div className="text-sm">
{t("settings.general.update.subtitle")}
</div>
</div>
<Switch.Root
checked={settings.autoupdate}
onClick={() => toggleAutoupdate()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold">
{t("settings.general.lowPower.title")}
</div>
<div className="text-sm">
{t("settings.general.lowPower.subtitle")}
</div>
</div>
<Switch.Root
checked={settings.lowPower}
onClick={() => toggleLowPower()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold">
{t("settings.general.startup.title")}
</div>
<div className="text-sm">
{t("settings.general.startup.subtitle")}
</div>
</div>
<Switch.Root
checked={settings.autolaunch}
onClick={() => toggleAutolaunch()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold">
{t("settings.general.media.title")}
</div>
<div className="text-sm">
{t("settings.general.media.subtitle")}
</div>
</div>
<Switch.Root
checked={settings.media}
onClick={() => toggleMedia()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold">
{t("settings.general.hashtag.title")}
</div>
<div className="text-sm">
{t("settings.general.hashtag.subtitle")}
</div>
</div>
<Switch.Root
checked={settings.hashtag}
onClick={() => toggleHashtag()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold">
{t("settings.general.notification.title")}
</div>
<div className="text-sm">
{t("settings.general.notification.subtitle")}
</div>
</div>
<Switch.Root
checked={settings.notification}
disabled={settings.notification}
onClick={() => toggleNofitication()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold">
{t("settings.general.translation.title")}
</div>
<div className="text-sm">
{t("settings.general.translation.subtitle")}
</div>
</div>
<Switch.Root
checked={settings.translation}
onClick={() => toggleTranslation()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
{settings.translation ? (
<div className="flex w-full items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold">
{t("global.apiKey")}
</div>
<div className="relative w-full">
<input
type="password"
spellCheck={false}
value={apiKey}
onChange={(e) => setAPIKey(e.target.value)}
className="w-full border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none h-9 rounded-lg ring-0 placeholder:text-neutral-600 bg-neutral-100 dark:bg-neutral-900"
/>
<div className="h-9 absolute right-0 top-0 inline-flex items-center justify-center">
<button
type="button"
onClick={saveApi}
className="mr-1 h-7 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
{t("global.save")}
</button>
</div>
</div>
</div>
) : null}
<div className="flex w-full items-start gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold">
{t("settings.general.appearance.title")}
</div>
<div className="flex flex-1 gap-6">
<button
type="button"
onClick={() => changeTheme("light")}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={cn(
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
settings.appearance === "light"
? "bg-blue-500 text-white"
: "bg-neutral-100 dark:bg-neutral-900",
)}
>
<LightIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t("settings.general.appearance.light")}
</p>
</button>
<button
type="button"
onClick={() => changeTheme("dark")}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={cn(
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
settings.appearance === "dark"
? "bg-blue-500 text-white"
: "bg-neutral-100 dark:bg-neutral-900",
)}
>
<DarkIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t("settings.general.appearance.dark")}
</p>
</button>
<button
type="button"
onClick={() => changeTheme("auto")}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={cn(
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
settings.appearance === "auto"
? "bg-blue-500 text-white"
: "bg-neutral-100 dark:bg-neutral-900",
)}
>
<SystemModeIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t("settings.general.appearance.system")}
</p>
</button>
</div>
</div>
</div>
</div>
);
}
-157
View File
@@ -1,157 +0,0 @@
import { useArk } from "@lume/ark";
import { useStorage } from "@lume/storage";
import * as Switch from "@radix-ui/react-switch";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export function NWCScreen() {
const ark = useArk();
const storage = useStorage();
const [t] = useTranslation();
const [settings, setSettings] = useState({
nwc: false,
instantZap: storage.settings.instantZap,
});
const [walletConnectURL, setWalletConnectURL] = useState<null | string>(null);
const [amount, setAmount] = useState("21");
const saveNWC = async () => {
try {
if (!walletConnectURL.startsWith("nostr+walletconnect:")) {
return toast.error(
"Connect URI is required and must start with format nostr+walletconnect:, please check again",
);
}
const uriObj = new URL(walletConnectURL);
const params = new URLSearchParams(uriObj.search);
if (params.has("relay") && params.has("secret")) {
await storage.createPrivkey(
`${ark.account.pubkey}.nwc`,
walletConnectURL,
);
storage.nwc = walletConnectURL;
setWalletConnectURL(walletConnectURL);
setSettings((state) => ({ ...state, nwc: true }));
} else {
return toast.error("Connect URI is not valid, please check again");
}
} catch (e) {
toast.error(String(e));
}
};
const toggleInstantZap = async () => {
await storage.createSetting("instantZap", String(+!settings.instantZap));
setSettings((state) => ({ ...state, instantZap: !settings.instantZap }));
};
const saveAmount = async () => {
await storage.createSetting("zapAmount", amount);
};
const remove = async () => {
await storage.removePrivkey(`${ark.account.pubkey}.nwc`);
setWalletConnectURL("");
setSettings((state) => ({ ...state, nwc: false }));
storage.nwc = null;
};
useEffect(() => {
if (storage.nwc) {
setSettings((state) => ({ ...state, nwc: true }));
setWalletConnectURL(storage.nwc);
}
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex flex-col gap-6">
<div className="flex w-full items-center justify-between">
<div className="flex w-full items-start gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold">
{t("settings.zap.nwc")}
</div>
<div className="flex flex-col items-end gap-2 w-full">
<textarea
spellCheck={false}
value={walletConnectURL}
onChange={(e) => setWalletConnectURL(e.target.value)}
className="w-full h-24 resize-none border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none rounded-lg ring-0 placeholder:text-neutral-600 bg-neutral-100 dark:bg-neutral-900"
/>
{!settings.nwc ? (
<button
type="button"
onClick={saveNWC}
className="h-8 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
{t("global.save")}
</button>
) : (
<button
type="button"
onClick={remove}
className="h-8 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
{t("global.delete")}
</button>
)}
</div>
</div>
</div>
{settings.nwc ? (
<>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold">
{t("settings.zap.instant.title")}
</div>
<div className="text-sm">
{t("settings.zap.instant.subtitle")}
</div>
</div>
<Switch.Root
checked={settings.instantZap}
onClick={() => toggleInstantZap()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex w-full items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold">
{t("settings.zap.defaultAmount")}
</div>
<div className="relative w-full">
<input
type="number"
spellCheck={false}
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none h-9 rounded-lg ring-0 placeholder:text-neutral-600 bg-neutral-100 dark:bg-neutral-900"
/>
<div className="h-9 absolute right-0 top-0 inline-flex items-center justify-center">
<button
type="button"
onClick={saveAmount}
className="mr-1 h-7 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
{t("global.save")}
</button>
</div>
</div>
</div>
</div>
</>
) : null}
</div>
</div>
);
}
@@ -1,248 +0,0 @@
import { useArk } from "@lume/ark";
import { CheckCircleIcon, LoaderIcon, UnverifiedIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { NDKKind, NDKUserProfile } from "@nostr-dev-kit/ndk";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AvatarUpload } from "./components/avatarUpload";
import { CoverUpload } from "./components/coverUpload";
export function ProfileSettingScreen() {
const ark = useArk();
const storage = useStorage();
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState("");
const [banner, setBanner] = useState("");
const [nip05, setNIP05] = useState({ verified: true, text: "" });
const { t } = useTranslation();
const {
register,
handleSubmit,
setError,
formState: { isValid, errors },
} = useForm({
defaultValues: async () => {
const res: NDKUserProfile = queryClient.getQueryData([
"user",
ark.account.pubkey,
]);
if (res.image) {
setPicture(res.image);
}
if (res.banner) {
setBanner(res.banner);
}
if (res.nip05) {
setNIP05((prev) => ({ ...prev, text: res.nip05 }));
}
return res;
},
});
const onSubmit = async (data: NDKUserProfile) => {
// start loading
setLoading(true);
let content = {
...data,
picture,
banner,
};
if (data.nip05) {
const verify = ark.validateNIP05({
pubkey: ark.account.pubkey,
nip05: data.nip05,
});
if (verify) {
content = { ...content, nip05: data.nip05 };
} else {
setNIP05((prev) => ({ ...prev, verified: false }));
setError("nip05", {
type: "manual",
message: "Can't verify your NIP-05, please check again",
});
}
}
const publish = await ark.createEvent({
kind: NDKKind.Metadata,
tags: [],
content: JSON.stringify(content),
});
if (publish) {
// invalid cache
await storage.clearProfileCache(ark.account.pubkey);
await queryClient.setQueryData(["user", ark.account.pubkey], () => {
return content;
});
// notify
toast.success("You've updated profile successfully.");
// reset state
setPicture(null);
setBanner(null);
}
setLoading(false);
};
return (
<div className="mx-auto w-full max-w-lg">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
<div className="mb-5 flex flex-col items-center justify-center bg-neutral-100 dark:bg-neutral-900 rounded-xl">
<div className="relative h-44 w-full">
{banner ? (
<img
src={banner}
alt="user's banner"
className="h-full w-full rounded-t-xl object-cover"
/>
) : (
<div className="h-full w-full rounded-t-xl bg-neutral-200 dark:bg-neutral-800" />
)}
<div className="absolute right-4 top-4">
<CoverUpload setBanner={setBanner} />
</div>
</div>
<div className="-mt-7 mb-5 px-4 flex flex-col gap-4 items-center z-10 relative">
<div className="size-14 overflow-hidden rounded-xl ring-2 ring-white dark:ring-black">
<img
src={picture}
alt="user's avatar"
className="h-14 w-14 rounded-xl object-cover bg-white dark:bg-black"
/>
</div>
<AvatarUpload setPicture={setPicture} />
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label
htmlFor="displayName"
className="text-sm font-semibold uppercase tracking-wider"
>
{t("user.displayName")}
</label>
<input
type={"text"}
{...register("display_name")}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider"
>
{t("user.name")}
</label>
<input
type={"text"}
{...register("name")}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="nip05"
className="text-sm font-semibold uppercase tracking-wider"
>
NIP-05
</label>
<div className="relative">
<input
{...register("nip05")}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 transform">
{nip05.verified ? (
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-teal-500 px-1 pr-1.5 text-xs font-medium text-white">
<CheckCircleIcon className="h-4 w-4" />
{t("user.verified")}
</span>
) : (
<span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 pl-1 pr-1.5 text-xs font-medium text-white">
<UnverifiedIcon className="h-4 w-4" />
{t("user.unverified")}
</span>
)}
</div>
{errors.nip05 && (
<p className="mt-1 text-sm text-red-400">
{errors.nip05.message.toString()}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider"
>
{t("user.website")}
</label>
<input
type={"text"}
{...register("website", { required: false })}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider"
>
{t("user.lna")}
</label>
<input
type={"text"}
{...register("lud16", { required: false })}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider"
>
{t("user.bio")}
</label>
<textarea
{...register("about")}
spellCheck={false}
className="relative h-36 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="absolute right-4 bottom-4">
<button
type="submit"
disabled={!isValid || loading}
className="inline-flex items-center justify-center w-24 pb-[2px] font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
t("global.update")
)}
</button>
</div>
</div>
</form>
</div>
);
}
-36
View File
@@ -1,36 +0,0 @@
import react from "@vitejs/plugin-react-swc";
import million from "million/compiler";
import { defineConfig } from "vite";
import topLevelAwait from "vite-plugin-top-level-await";
import viteTsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [
million.vite({
auto: {
threshold: 0.05,
},
mute: true,
}),
react(),
viteTsconfigPaths(),
topLevelAwait({
// The export name of top-level await promise for each chunk module
promiseExportName: "__tla",
// The function to generate import names of top-level await promise in each chunk module
promiseImportName: (i) => `__tla_${i}`,
}),
],
envPrefix: ["VITE_", "TAURI_"],
build: {
target: process.env.TAURI_PLATFORM === "windows" ? "chrome105" : "safari13",
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_DEBUG,
outDir: "../../dist",
},
server: {
strictPort: true,
port: 3000,
},
clearScreen: false,
});
+26
View File
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
src/router.gen.ts
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lume Desktop</title>
</head>
<body
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>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
{
"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.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/query-persist-client-core": "^5.49.1",
"@tanstack/react-query": "^5.49.2",
"@tanstack/react-router": "^1.43.3",
"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.43.3",
"@tanstack/router-vite-plugin": "^1.43.1",
"@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.39",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.2",
"vite": "^5.3.2",
"vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.2"
}
}
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

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