Compare commits

...

566 Commits

Author SHA1 Message Date
reya
135d0918b3 feat: add check for updates 2024-05-12 15:07:53 +07:00
reya
e1fbcf0460 chore: fix ci again 2024-05-12 08:57:57 +07:00
reya
99aaf3da82 fix: misconfigure in github actions 2024-05-12 08:54:24 +07:00
reya
3ef13e43f1 chore: update github ci 2024-05-12 08:38:23 +07:00
reya
8939196ae4 chore: bump version to 4.0 2024-05-12 08:20:52 +07:00
reya
571d4b4004 feat: improve search 2024-05-12 08:18:25 +07:00
reya
73f80f27fb feat: add basic relay management in rust 2024-05-11 12:28:07 +07:00
雨宮蓮
b46a5cf68f Merge pull request #186 from kasugamirai/chore
feat: Refactor code to improve error handling and readability
2024-05-10 07:13:32 +07:00
xy
8c0d03aed0 feat: Refactor code to improve error handling and readability 2024-05-09 19:09:55 +09:00
reya
777eb15b4f feat: onboarding 2024-05-09 15:06:42 +07:00
reya
c8e1b8b8bd feat: add unread notification badge to dock icon 2024-05-07 14:38:00 +07:00
reya
437cd71f7e feat: improve editor 2024-05-07 14:14:21 +07:00
reya
afb7c87fa3 feat: add bell 2024-05-07 08:29:58 +07:00
reya
c843626bca feat: add notification screen 2024-05-06 15:17:34 +07:00
reya
28337e5915 feat: improve dedup events 2024-05-04 08:29:52 +07:00
雨宮蓮
a4aef25adb final design (#184)
* feat: redesign

* feat: update other columns to new design

* chore: small fixes

* fix: better manage external webview

* feat: redesign note

* feat: update ui

* chore: update

* chore: update

* chore: polish ui

* chore: update auth ui

* feat: finalize note design

* chore: small fixes

* feat: add window management in rust

* chore: format

* feat: update ui for event screen

* feat: update event screen

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

* chore: polish
2024-04-24 10:18:51 +07:00
雨宮蓮
174a3cc74e feat: add search window (NIP-50) (#181)
* feat: add search window
* chore: improve search ui
2024-04-23 15:34:08 +07:00
reya
c00a7749b4 feat: use nip44 to encrypt key-value store 2024-04-23 08:04:42 +07:00
Ren Amamiya
c755b8d137 feat: add ability change column name on the fly (#180)
Co-authored-by: reya <reya@lume.nu>
2024-04-22 14:33:14 +07:00
17766d29d6 chore: update deps 2024-04-22 13:18:34 +07:00
3b13dfeed8 chore: update github action 2024-04-20 18:13:36 +07:00
17ba79e01b chore: update app icon 2024-04-20 08:58:00 +07:00
bafad544e9 chore: update deps and bump version 2024-04-19 14:21:12 +07:00
89c36423ae feat: add nip46 2024-04-18 15:09:33 +07:00
cd31b99559 feat: improve official columns 2024-04-18 07:50:46 +07:00
f3c52237fa fix: privacy setting is not working 2024-04-17 13:50:50 +07:00
413d8d82df feat: finish settings screen 2024-04-16 15:28:38 +07:00
2eb2010d43 chore: upgrade deps 2024-04-16 08:55:36 +07:00
94d400cab2 feat: support nip-36 2024-04-16 07:49:44 +07:00
09b143cb08 feat: auto resize mini webview when main webview resized 2024-04-15 13:30:55 +07:00
e3ede34108 feat: revert to sqlite 2024-04-14 09:05:46 +07:00
ed6aca41ea feat: add new spinner component 2024-04-13 15:01:27 +07:00
89f577fbef feat: upgrade to rust nostr 0.30.0 and migrate to nostrdb 2024-04-13 08:53:31 +07:00
a14aeaeb55 feat: add empty state and polish trending column 2024-04-13 08:30:58 +07:00
reya
35cf0abda4 fix: support windows 2024-04-12 18:35:19 +07:00
8a7b246315 chore: update deps 2024-04-11 07:47:37 +07:00
Ren Amamiya
d98c6d0709 Merge pull request #175 from lumehq/v4/beta
Prepare for v4 - beta
2024-04-11 07:45:43 +07:00
bda20e8fe8 chore: small updates 2024-04-11 07:44:41 +07:00
c342daecc8 feat: improve 2024-04-10 14:11:05 +07:00
5e6692cd6d feat: re add group column 2024-04-09 14:05:50 +07:00
420be77b5c feat: add nstore 2024-04-07 15:11:20 +07:00
999073f84c feat: readd for you column 2024-04-04 13:47:15 +07:00
174b28f1a7 chore: update deps 2024-04-03 08:01:40 +07:00
763cb10e85 chore: clean up 2024-04-03 07:29:46 +07:00
89bb8d88f6 feat: settings screens 2024-04-02 13:19:26 +07:00
09aa2ecafc feat: re-add trending column 2024-03-30 10:50:45 +07:00
7271e9ea87 feat: add custom traffic light inset for macos 2024-03-29 13:26:35 +07:00
a02e496b29 chore: update tauri 2024-03-29 07:45:37 +07:00
cbbf5eaf50 feat: add create account flow 2024-03-28 15:12:43 +07:00
d3fa59d2b1 chore: polish 2024-03-27 08:36:34 +07:00
aa23e39334 feat: update new icons 2024-03-26 14:58:06 +07:00
c8e2018fd0 feat: update tray icon 2024-03-26 13:08:03 +07:00
6e489f1c49 wip: onboarding 2024-03-25 14:22:06 +07:00
a49b88ab35 Merge branch 'main' into v4/beta 2024-03-23 07:24:58 +07:00
31839531ea feat: improve open store column 2024-03-22 15:25:53 +07:00
ec0f3fabc0 feat: improve column management 2024-03-22 11:28:54 +07:00
dd7155a3a6 feat: update default column informations 2024-03-21 14:53:08 +07:00
cb565ff35b feat: readd default columns 2024-03-20 15:12:54 +07:00
5d59040224 chore: follow up 2024-03-19 15:36:20 +07:00
7fabf949c6 feat: move column manager to rust 2024-03-19 14:56:24 +07:00
ea5120e2f0 chore: upgrade tauri and bump version 2024-03-19 12:46:19 +07:00
14f07dfea8 feat: add lume store screen 2024-03-19 08:21:16 +07:00
Ren Amamiya
1de48cc640 Merge pull request #174 from fernandolguevara/minors/styling-scrollbar
feature: make scrollbar nicer
2024-03-19 07:10:28 +07:00
Fernando López Guevara
f72eb456e8 feature: make scrollbar nicer 2024-03-18 09:42:25 -03:00
05b52564e0 feat: column manager 2024-03-18 10:50:08 +07:00
c8e014f33e chore: clean up 2024-03-16 18:45:54 +07:00
46cc01e0ee feat: child webview 2024-03-16 15:05:06 +07:00
16e6d234e5 feat: space 2024-03-14 14:22:41 +07:00
3005d27403 feat: update fetch opg function 2024-03-13 10:24:45 +07:00
1be84f3139 chore: release v4.0.0-alpha+2 2024-03-13 09:40:30 +07:00
Ren Amamiya
04bde7dd43 Merge pull request #172 from lumehq/v4/functions
Some polish for v4
2024-03-13 09:39:57 +07:00
f1504d99ac feat: add mock tray menu 2024-03-13 09:19:25 +07:00
e928f2ee37 feat: polish 2024-03-13 08:40:18 +07:00
Ren Amamiya
ccab78ca11 Merge pull request #171 from lumehq/v4/zap
Add zap to v4
2024-03-11 15:39:35 +07:00
5b13c3e45c feat: auto close window if zapp successfully 2024-03-11 15:38:54 +07:00
e6b97ab9ae feat: add zap 2024-03-11 15:36:17 +07:00
bb6badfed6 feat: add zap setup screen 2024-03-10 16:39:23 +07:00
Ren Amamiya
0c4b309a11 Merge pull request #170 from lumehq/v4/home-screen
v4/home-screen
2024-03-09 10:09:29 +07:00
f4400d711f feat: improve home screen 2024-03-09 10:08:50 +07:00
a3e46aa96b fix: crash when database is not exist 2024-03-08 14:47:36 +07:00
a4fdcfdf0b feat: add local and global newsfeeds 2024-03-07 15:39:43 +07:00
25d07303a3 fix: build error in windows and linux 2024-03-06 15:32:51 +07:00
0e1e7524c9 chore: bump version 2024-03-06 15:05:17 +07:00
Ren Amamiya
32d770eb91 Merge pull request #169 from lumehq/v4/onboarding
Add basic onboarding dialog to v4
2024-03-06 15:00:07 +07:00
e93cbf78d5 feat: add npub to file associations 2024-03-06 14:40:55 +07:00
71a2290b8f feat: add backup dialog 2024-03-06 14:40:00 +07:00
95294a80cb feat: simplify account management 2024-03-06 11:11:24 +07:00
8eaf47f6d2 feat: add login dialog 2024-03-06 09:42:44 +07:00
86183d799a fix: package version in windows 2024-03-02 17:59:36 +07:00
2dbfff3c17 chore: release v4.0.0-alpha.0 2024-03-02 16:08:01 +07:00
Ren Amamiya
d1f5c372ae Merge pull request #166 from lumehq/feat/rust-nostr
Rust Nostr
2024-03-02 15:32:13 +07:00
13281455ed chore: refer old branch in readme 2024-03-02 15:31:41 +07:00
9cfb32be4d feat: update readme 2024-03-02 15:29:45 +07:00
cb0672c22a Merge branch 'main' into feat/rust-nostr 2024-03-02 15:22:11 +07:00
ca0e041731 feat: improve 2024-03-02 15:21:28 +07:00
cfcb9bc6ed feat: improve ui 2024-02-29 13:02:16 +07:00
09df8672d0 feat: add zap command 2024-02-27 15:37:49 +07:00
2403231ac4 feat: add user screen 2024-02-27 13:42:59 +07:00
98ef1927f2 feat: editor 2024-02-26 15:10:42 +07:00
63db8b1423 chore: clean up 2024-02-25 15:52:47 +07:00
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
88a6c3c81f feat: account switcher 2024-02-24 14:41:57 +07:00
84584a4d1f feat: add editor screen 2024-02-23 14:56:24 +07:00
64286aa354 wip 2024-02-22 14:01:29 +07:00
e9ce932646 chore: remove storage layer 2024-02-22 08:58:45 +07:00
Ren Amamiya
4c28b4879c chore: update readme 2024-02-21 15:04:30 +07:00
9127d5c5ea feat: add more metadata commands 2024-02-21 14:29:54 +07:00
090e54ec5a feat: add contact list commands 2024-02-20 14:46:21 +07:00
d0c9f93ebb wip: small updates 2024-02-20 10:26:19 +07:00
e2cdc5b576 feat: account selection screen 2024-02-19 14:13:33 +07:00
bfe35ad885 wip: multi accounts 2024-02-19 09:58:27 +07:00
0f06522c28 wip: refactor 2024-02-17 16:01:53 +07:00
f47eba5af7 feat: upgrade to rust nostr 0.28 2024-02-17 09:29:37 +07:00
f28a7ae82f wip: update design 2024-02-16 14:11:49 +07:00
296b11b7b8 wip: basic support multi windows 2024-02-16 09:58:07 +07:00
a0d9a729dd feat: add tauri devtools 2024-02-15 14:41:32 +07:00
1de8c7240d wip: desktop2 2024-02-15 14:32:40 +07:00
70126ef1b3 Merge branch 'main' into feat/rust-nostr 2024-02-15 12:48:20 +07:00
cdf29f8a54 wip: update 2024-02-15 12:47:15 +07:00
6171b9bed1 wip: tired... 2024-02-14 15:51:06 +07:00
60fd09000b wip: new design 2024-02-14 08:29:24 +07:00
4292def206 feat: add more nostr commands 2024-02-13 09:36:11 +07:00
90f149b09c chore: clean up 2024-02-13 08:57:24 +07:00
ed52105c02 wip: migrate to desktop2 2024-02-12 20:04:45 +07:00
1950cb59a2 wip: migrate to desktop2 2024-02-12 09:08:35 +07:00
Ren Amamiya
9753e6d6b4 Merge pull request #167 from anthony-robin/french-translations
Add french translations 🇫🇷
2024-02-12 07:55:46 +07:00
Anthony Robin
3488f05960 Add french translations 🇫🇷
This PR adds support for french translations into Lume.
2024-02-11 17:09:59 +01:00
c809ab6b4e feat: add desktop2 2024-02-11 09:30:18 +07:00
Ren Amamiya
c6674c4a2d Merge pull request #165 from higedamc/main
Update ja.json
2024-02-11 08:10:36 +07:00
35c5b5fb78 chore: fix build 2024-02-10 11:19:18 +07:00
739ba63e6c wip: migrate frontend to new backend 2024-02-09 15:33:27 +07:00
ec78cf8bf7 feat: migrate frontend to new backend 2024-02-08 21:24:08 +07:00
17052aeeaa feat: add new account management 2024-02-08 17:17:45 +07:00
d7bbda6e7b fix: remove native db 2024-02-07 14:26:58 +07:00
6a08c1de10 feat: add all nostr events command 2024-02-07 14:04:57 +07:00
3c4bd39384 feat: update rust nostr 2024-02-06 19:28:46 +07:00
a4069dae99 chore: clean up 2024-02-06 09:15:20 +07:00
HigeMan
55cd556cd6 Update ja.json 2024-02-06 08:45:17 +09:00
a21da11a91 feat: add desktop2 2024-02-05 14:18:27 +07:00
08fa7de01d feat: add check active account before init client 2024-02-05 13:36:10 +07:00
2a58326cd1 chore: update dependencies 2024-02-05 08:30:06 +07:00
8a17c36a5b feat: upgrade to tauri beta 2024-02-05 08:14:58 +07:00
7bd6f6a8db fix: remove surrealdb and clean up 2024-02-05 07:46:41 +07:00
3ba870be4b feat: add verify signer 2024-02-04 10:46:50 +07:00
bd2b6a3759 feat: add surreal db 2024-02-03 11:24:08 +07:00
a3a8f57bfc feat: add more nostr commands 2024-02-03 09:13:17 +07:00
fd393a4d30 feat: add basic nostr connection 2024-02-02 09:09:14 +07:00
e8bd48e51b feat: add rust nostr 2024-02-01 12:54:25 +07:00
0191180f31 chore: rename 2024-02-01 09:15:25 +07:00
60ed56b1b9 chore: bump version 2024-02-01 08:40:27 +07:00
Ren Amamiya
da722afed3 Merge pull request #154 from luminous-devs/next
v3.0.1
2024-02-01 08:36:21 +07:00
d8eb51e49c Revert "chore: update dependencies"
This reverts commit c700a45ab6.
2024-02-01 08:18:51 +07:00
c700a45ab6 chore: update dependencies 2024-02-01 07:59:25 +07:00
b806a34edb chore: remove submodule and clean up flatpak 2024-01-31 14:31:16 +07:00
21989e6fa5 fix: minor improves 2024-01-31 14:20:03 +07:00
0539c5649d feat: add waifu column and fix wrong package 2024-01-31 11:04:47 +07:00
ad488ff72d feat: add global column 2024-01-31 09:12:26 +07:00
02e0309a41 feat: refactor rust commands 2024-01-31 08:20:39 +07:00
b7f4af7883 fix: respect NIP spec 2024-01-31 07:29:47 +07:00
cc48a4f36b chore: update dependencies 2024-01-30 15:33:24 +07:00
46ed3330fc feat: add hover card to user 2024-01-30 14:45:46 +07:00
1fa1872ca6 feat: add trending notes column 2024-01-30 14:22:56 +07:00
Ren Amamiya
c389a23365 Merge pull request #153 from luminous-devs/feat/improve-updater
feat: Add new update bubble to navigation
2024-01-30 13:18:52 +07:00
eaf9bda077 feat: add new update notify to navigation 2024-01-30 13:16:39 +07:00
84a248a5a9 chore: fix typos 2024-01-30 10:01:45 +07:00
Ren Amamiya
711c1d561a Merge pull request #151 from luminous-devs/feat/multi-lang
Add support for multi-languages
2024-01-30 09:12:06 +07:00
21210b4336 feat: migrate activity screen to i18n 2024-01-30 09:10:26 +07:00
3bd480b75e feat: optimize locale loader 2024-01-30 08:55:49 +07:00
2b19650e46 feat: migrate onboarding to i18n 2024-01-30 08:13:12 +07:00
23482531c5 feat: migrate settings screen to i18n 2024-01-29 14:57:00 +07:00
cfda9ba899 feat: migrate ui components to i18n 2024-01-29 13:38:22 +07:00
698bd78684 feat: migrate note component to i18n 2024-01-29 10:22:55 +07:00
b97676dd3e feat: add translation to relay screen 2024-01-29 09:30:33 +07:00
25ae4f2201 feat: add multi-lang 2024-01-29 09:12:44 +07:00
Ren Amamiya
59435ccd13 Merge pull request #150 from jurraca/add-libappindicator
add libappindicator to runtime libs in nix shell
2024-01-28 17:35:40 +07:00
jurraca
e81912c5e9 add libappindicator to runtime libs in nix shell 2024-01-28 08:24:25 +00:00
af1b4e60d3 web: update download link 2024-01-28 08:45:04 +07:00
648cbf6f80 chore: fix ci 2024-01-28 08:05:36 +07:00
7b06a82ee7 chore: bump version and fix gh action 2024-01-28 07:40:08 +07:00
Ren Amamiya
d18de93c60 Merge pull request #148 from luminous-devs/feat/improve-perf
Improve overall performance
2024-01-27 20:01:55 +07:00
df15eb7a03 Merge branch 'main' into feat/improve-perf 2024-01-27 17:56:50 +07:00
Ren Amamiya
06674df6cc Merge pull request #137 from kogeletey/feat/package-flatpak
Package lume in flatpak
2024-01-27 17:34:47 +07:00
reya
8295625a44 feat: polish on windows 2024-01-27 15:53:19 +07:00
b11e2a4291 fix: update mention popup style 2024-01-27 11:16:17 +07:00
353c18bb76 feat: update ark provider 2024-01-27 09:08:41 +07:00
kogeletey
02b0c9e48a merge remote-tracking branch 'origin/main' into feat/package-flatpak 2024-01-26 19:49:27 +03:00
kogeletey
ff73c8ac88 feat: supporting hash of github actions cache 2024-01-26 19:37:12 +03:00
Ren Amamiya
bc48391a1a Merge pull request #147 from luminous-devs/feat/improve-design
Improve overal design
2024-01-26 14:21:27 +07:00
b0a443c002 feat: polish 2024-01-26 14:15:25 +07:00
bef1f136ad chore: bump version 2024-01-26 12:43:19 +07:00
9ba584bf14 feat: improve onboarding 2024-01-26 10:17:23 +07:00
kogeletey
43509fc943 feat: supported flatpak version v3 2024-01-25 15:00:08 +03:00
kogeletey
4a99eb94e2 feat: supported flatpak 2024-01-25 14:59:48 +03:00
74426e13c8 wip: improve onboarding 2024-01-25 15:25:40 +07:00
bd45c36072 feat: fix typos and other stuffs 2024-01-25 09:49:04 +07:00
c13aefcd15 feat: update dependencies 2024-01-25 08:35:17 +07:00
167caee8bc feat: improve ui contrast 2024-01-25 08:14:25 +07:00
d527078d5c feat: redesign column header 2024-01-24 15:08:55 +07:00
Ren Amamiya
763ace5ddf Merge pull request #145 from luminous-devs/feat/search
feat: add search based on NIP-50
2024-01-24 13:36:17 +07:00
057c57b70f feat: add empty state to search dialog 2024-01-24 13:34:27 +07:00
cb71786ac1 feat: add basic search dialog 2024-01-23 13:07:24 +07:00
Ren Amamiya
67afeac198 Merge pull request #143 from luminous-devs/apps/web
Add landing page
2024-01-22 14:41:24 +07:00
f4ee25de8e feat: add landing page 2024-01-22 14:40:39 +07:00
445a218a9e Merge branch 'main' into next 2024-01-21 09:54:47 +07:00
f09139ffbe final 2024-01-21 09:43:46 +07:00
446721729b feat: polish 2024-01-20 20:14:26 +07:00
reya
e0250d7f5c chore: fix some issues on windows 2024-01-20 10:15:27 +07:00
9fcdac4edb feat: add suggest screen 2024-01-20 14:51:13 +07:00
b726ae3c7c feat: add for you column 2024-01-20 09:06:00 +07:00
a3460418f6 feat: add interest screen to onboarding 2024-01-19 14:53:26 +07:00
f65175f11e feat: polish 2024-01-19 08:24:13 +07:00
16efd495a0 chore: clean up 2024-01-19 07:45:28 +07:00
ed6423e4aa feat: polish 2024-01-18 15:09:16 +07:00
0e9418949b feat: add instant zap 2024-01-18 13:56:35 +07:00
240fe8bc7c feat: fix relay manager 2024-01-18 09:41:53 +07:00
c3482cddd8 feat: improve zap 2024-01-18 08:22:35 +07:00
d13e7b3ef6 feat: polish 2024-01-18 07:37:40 +07:00
47800bd2ff chore: upgrade tauri 2024-01-17 13:07:01 +07:00
c0305db5fc chore: update and fix dependencies 2024-01-17 12:39:04 +07:00
0b745cb40e feat: add reply form 2024-01-17 12:24:04 +07:00
a20f5ca15d feat: polish 2024-01-17 09:30:32 +07:00
c29b4e173e feat: polish 2024-01-17 08:50:43 +07:00
33dd8b1d8a feat: better error handler 2024-01-16 19:56:07 +07:00
1503d90bd5 fix: tauri cache 2024-01-16 14:51:06 +07:00
6581ffb92b feat: polish 2024-01-16 14:49:00 +07:00
939dfd9cc1 feat: fix errros 2024-01-16 08:37:42 +07:00
7744a5e17c feat: update translation 2024-01-15 20:01:06 +07:00
3301af5cbb feat: move nwc to settings 2024-01-15 15:33:05 +07:00
3f1218e7bc feat: add tutorial 2024-01-15 14:06:11 +07:00
fbcb3ae6dc feat: add register translate api 2024-01-15 09:51:49 +07:00
e93aedb703 feat: update default column list 2024-01-15 08:36:23 +07:00
dae4b1d52b feat: redesign relay screen 2024-01-14 18:05:36 +07:00
f908c46a19 feat: polish 2024-01-14 09:39:56 +07:00
ab27bd5f44 feat: polish 2024-01-13 20:24:52 +07:00
72870bb131 feat: add activity screen 2024-01-13 17:12:44 +07:00
1822eac488 feat(ark): add user component 2024-01-13 08:21:49 +07:00
0487b8a801 feat: refactor 2024-01-12 20:32:45 +07:00
67c6177291 chore: update 2024-01-12 15:14:49 +07:00
ad6ae6745d chore: fixed circular import 2024-01-12 14:46:50 +07:00
a9d10ff93b feat: polish note component 2024-01-12 13:56:20 +07:00
e0d4c53098 feat: update onboarding flow 2024-01-12 10:12:06 +07:00
2c8571ecc7 wip: new activity sidebar 2024-01-11 21:00:42 +07:00
a8cd34d998 chore: small fixes 2024-01-11 07:56:28 +07:00
a5ad4fe05c feat: redesign navigation bar 2024-01-10 13:57:44 +07:00
f2504071cd feat: update login flow 2024-01-10 09:22:13 +07:00
73f90ebaf9 chore: minor fixes and updates 2024-01-09 08:35:30 +07:00
c172c0f80f feat: add onboarding modal 2024-01-08 20:18:07 +07:00
aa80301778 refactor: add event and user routes to default ui 2024-01-08 09:30:04 +07:00
c04ca3a1ab fix: migarate to virtua 0.2.0 2024-01-08 08:03:19 +07:00
3eae38e1cb chore: update dependencies 2024-01-07 20:47:48 +07:00
87099c6388 feat: update create account flow 2024-01-07 16:39:05 +07:00
7554a35c31 chore: update config 2024-01-07 07:44:37 +07:00
70707f69c8 feat: update create account screen 2024-01-07 07:42:08 +07:00
Ren Amamiya
a98ffd4887 Merge pull request #135 from fernandolguevara/feat-open-notes-edit-rail-title
feat(rail): edit title & open user notes
2024-01-07 07:39:18 +07:00
Fernando López Guevara
2e23b3ae06 feat(rail): edit title & open user notes 2024-01-06 11:38:34 -03:00
8e8e6fe244 chore: restructure 2024-01-05 07:31:08 +07:00
2726bfd595 feat: support nip 89 2024-01-04 15:10:52 +07:00
542b6033c2 refactor(note): only support kind 1 2024-01-04 12:35:21 +07:00
fcde669685 feat(editor): add hot key and update function 2024-01-04 10:44:00 +07:00
f4cbcee8b4 feat: add editor 2024-01-04 08:52:45 +07:00
ba13ac7535 chore: restructure packages 2024-01-03 11:12:36 +07:00
9f27d68533 chore: clean up 2024-01-03 11:03:56 +07:00
698f5a5d6d feat(columns): add default column 2024-01-02 12:28:48 +07:00
7856d6d49d feat(columns): add antenas column 2024-01-02 09:02:11 +07:00
a52fb3c437 feat(columns): add group column 2024-01-01 17:32:57 +07:00
499765c10a chore: update dependencies 2024-01-01 08:19:43 +07:00
56fab1dda6 feat: the last commit of year 2023-12-31 20:53:51 +07:00
b1d2496f8e feat(column): add hashtag column 2023-12-30 17:33:04 +07:00
ddbbcf41b5 chore: polish some components 2023-12-30 09:02:39 +07:00
55d6318614 refactor(ark): update note component 2023-12-29 14:14:39 +07:00
be333260f2 refactor(column): use context for manage column 2023-12-29 13:12:37 +07:00
e1edba8a78 chore: update dependencies 2023-12-29 08:26:13 +07:00
4fc3cc8a80 feat(column): add thread and user columns 2023-12-28 11:31:47 +07:00
893f3f7181 feat(icon): update navigation icons 2023-12-28 09:38:59 +07:00
4103b509d4 feat(parser): improve media parser 2023-12-28 08:44:55 +07:00
ed538c91c6 feat(columns): update timeline column 2023-12-27 15:01:40 +07:00
b4dac2d477 refactor(ark): add note provider 2023-12-27 10:52:13 +07:00
3956ed622d chore: fix build 2023-12-26 15:21:51 +07:00
e1db873bd5 refactor(ark): rename widget to column 2023-12-26 13:44:38 +07:00
227c2ddefa chore: monorepo 2023-12-25 14:28:39 +07:00
a6da07cd3f refactor: everything 2023-12-24 19:14:46 +07:00
9591d8626d chore: update dependencies 2023-12-23 09:05:52 +07:00
ee4e6b1ee6 chore: remove signal 2023-12-22 14:49:00 +07:00
a882ead649 chore: update icon 2023-12-22 14:10:23 +07:00
0522611669 feat(icon): update app and tray icons 2023-12-22 10:13:21 +07:00
2536630ff7 update dependencies 2023-12-22 07:52:24 +07:00
4670778181 feat(depot): update screens 2023-12-21 15:29:07 +07:00
a6ca2589ab feat(depot): update onboarding screen 2023-12-19 15:43:32 +07:00
d9e8d05db7 feat(ui): update ui consistent 2023-12-19 10:13:52 +07:00
ec2ac2dce3 feat(ark): add note component to ark 2023-12-19 08:06:10 +07:00
55298515af refactor(widget): migrate widget component to ark lib 2023-12-18 13:39:03 +07:00
344bdc0c66 feat(depot): add setting run depot at launch 2023-12-17 08:07:44 +07:00
ba88a4e0f2 feat(ark): update screen 2023-12-16 10:53:16 +07:00
17c64ee357 feat(ark): refactor 2023-12-16 07:47:00 +07:00
ba93bdbb91 feat(depot): initial work for depot 2023-12-15 09:15:30 +07:00
591373fd52 fix(layout): fix flickering on home layout 2023-12-14 14:30:49 +07:00
2fcc4dead1 feat(router): restructure 2023-12-14 13:22:03 +07:00
d9ab7893e0 feat: update format 2023-12-14 13:11:21 +07:00
Ren Amamiya
a93ebd3861 Merge pull request #132 from luminous-devs/feat/universal-titlebar
feat: add window titlebar
2023-12-11 08:57:31 +07:00
7c4ec71089 feat: add window titlebar 2023-12-11 08:56:00 +07:00
Ren Amamiya
e9d845cf25 Merge pull request #131 from luminous-devs/wip/cleanup
Clean up
2023-12-11 07:34:24 +07:00
8883be7ed6 upgrade to vite 5 2023-12-10 17:00:04 +07:00
132ea7f887 clean up 2023-12-10 16:53:07 +07:00
Ren Amamiya
f9402f5c4f Merge pull request #130 from luminous-devs/feat/ark
Introduction Ark
2023-12-10 11:19:04 +07:00
72a38e3aa7 polish 2023-12-10 08:39:40 +07:00
38e82a4feb prefetch data 2023-12-09 18:11:02 +07:00
6440680898 fix ark 2023-12-09 09:34:20 +07:00
e507187044 wip: fully migrate to ark 2023-12-08 12:39:15 +07:00
Ren Amamiya
feeb92b6ef Merge pull request #129 from vivganes/patch-2
Grammar touch-up + 1 more tip 🚀
2023-12-08 11:20:04 +07:00
Vivek Ganesan
2d4a77e8ed Grammar touch-up + 1 more tip 🚀 2023-12-08 08:47:54 +05:30
6f5ea1229d Merge branch 'main' into feat/ark 2023-12-08 09:43:35 +07:00
68886ad584 wip: migrate to ark 2023-12-08 09:32:48 +07:00
5f90bd0d22 update dependencies 2023-12-08 08:18:47 +07:00
8b434d577f fix nip-05 verification 2023-12-08 08:01:06 +07:00
f2b1458bd2 bump version & fix using nsecbunker with token 2023-12-07 18:49:55 +07:00
7507cd9ba1 wip: migrate to ark 2023-12-07 18:09:00 +07:00
0d43c13928 bump version 2023-12-07 11:55:49 +07:00
95124e5ded wip: ark 2023-12-07 11:50:25 +07:00
a42a2788ea fix nip-05 2023-12-06 10:15:41 +07:00
e30274dab3 fix typo 2023-12-06 09:07:32 +07:00
Ren Amamiya
740b7588bc Merge pull request #127 from luminous-devs/hotfix/2.2.1
v2.2.1
2023-12-06 08:09:44 +07:00
24c2ed4eb2 update 2023-12-06 08:08:11 +07:00
4006c0010e polish 2023-12-05 15:31:45 +07:00
7decf264d7 polish nsecbunker 2023-12-05 14:25:44 +07:00
482b218f74 improve error handling for useevent hook 2023-12-05 09:42:08 +07:00
e06b760e41 bump version 2023-12-04 14:14:50 +07:00
7efc35f622 replace media chrome with vidstack 2023-12-04 14:02:07 +07:00
8795923443 update 2023-12-04 13:36:16 +07:00
4093821fd0 clean up 2023-12-04 12:47:29 +07:00
b19637bdb7 remove misconfigure in react query 2023-12-04 12:17:16 +07:00
21e758ec13 update user component 2023-12-04 11:49:52 +07:00
48ab123850 improve relay screen 2023-12-04 09:26:19 +07:00
a401070031 update dependencies 2023-12-04 08:46:42 +07:00
e5e4109e79 bump version 2023-12-03 08:38:32 +07:00
Ren Amamiya
d62c814f33 Merge pull request #125 from luminous-devs/next
Lume v2.2.0
2023-12-03 08:37:37 +07:00
2a92b7c202 polish 2023-12-03 08:34:44 +07:00
255dcb43fe improve relay form 2023-12-02 17:53:45 +07:00
a528b646e3 add finish step to tutorial 2023-12-02 08:33:23 +07:00
fc35745c0d wip: tutorial 2023-12-01 15:45:43 +07:00
9ddf3471ce fix nsecbunker 2023-12-01 08:23:46 +07:00
8355ad6863 auto connect user relays 2023-11-30 20:15:54 +07:00
217ac490b1 fix outbox 2023-11-30 19:22:00 +07:00
092cf49227 improve relay connection 2023-11-30 18:19:24 +07:00
5318f6c4cb clean up 2023-11-30 17:24:07 +07:00
80f675cb54 improve notification and performance 2023-11-30 16:02:28 +07:00
6f68c2762b add prefetch data 2023-11-30 10:35:08 +07:00
f4390b29e2 revamp onboarding and launching process 2023-11-30 09:38:58 +07:00
00e4f9d357 clean up dependencies 2023-11-28 15:36:57 +07:00
d28d183620 Merge branch 'main' into next 2023-11-28 09:07:14 +07:00
3c6c9c86d1 2.1.7 2023-11-28 09:00:46 +07:00
bcd079c88e update dependencies 2023-11-28 08:34:21 +07:00
Ren Amamiya
d989d6ffad Merge pull request #123 from luminous-devs/feat/v2.1.6
Feat/v2.1.6
2023-11-27 12:01:25 +07:00
5229458746 bump version 2023-11-27 09:56:43 +07:00
2bfa1db816 update 2023-11-27 09:48:51 +07:00
8439428ce1 fix crash on settings screen 2023-11-26 15:01:13 +07:00
34dceef4a3 fix mention popup 2023-11-26 07:48:28 +07:00
Ren Amamiya
619bfb8dff Merge pull request #122 from luminous-devs/v2.1.4
v2.1.4
2023-11-26 07:22:13 +07:00
7759851541 clean up 2023-11-26 07:21:24 +07:00
9112c1c24a improve connection 2023-11-25 17:56:45 +07:00
24b21a9451 update 2023-11-25 16:03:05 +07:00
31a53b9c48 add @ suggestion popup 2023-11-25 15:41:18 +07:00
dc229f40cb fix new article layout 2023-11-25 11:07:31 +07:00
54ad1e6e1d fix new post layout 2023-11-25 09:22:15 +07:00
Ren Amamiya
065ccbbea4 Merge pull request #121 from luminous-devs/fix/nsecbunker
Fix stuck issue for connect with nsecbunker
2023-11-24 13:53:26 +07:00
74738c36cd disable blockUntilReady 2023-11-23 15:12:46 +07:00
Ren Amamiya
2fdf437789 Merge pull request #120 from luminous-devs/fix/logout
Fix logout function and other issues
2023-11-23 08:54:24 +07:00
731c72535c bump version 2023-11-23 08:52:47 +07:00
628102087e fix total account count function 2023-11-23 08:52:04 +07:00
536ea30ed2 fix logout function 2023-11-23 08:49:05 +07:00
8ee38cdb42 temp disable single-instance plugin 2023-11-22 17:27:09 +07:00
Ren Amamiya
a896300f23 Merge pull request #118 from luminous-devs/v2.1.2
v2.1.2
2023-11-22 16:18:02 +07:00
d3cf1200ba bump version 2023-11-22 16:13:06 +07:00
b5ac3df090 fix package 2023-11-22 16:10:20 +07:00
3b40dd6903 update dependencies 2023-11-22 15:27:19 +07:00
Ren Amamiya
efba6b20ea Merge pull request #117 from luminous-devs/feat/optional-updater
Make auto update is optional
2023-11-22 10:34:49 +07:00
Ren Amamiya
05fb56e5fc Merge pull request #116 from vivganes/patch-1
Little grammar corrections
2023-11-22 10:32:47 +07:00
Vivek Ganesan
59d9646e9f Little grammar corrections 2023-11-22 08:43:31 +05:30
b73d84fccb update storage provider 2023-11-22 09:05:10 +07:00
1929ceb72d add toast message 2023-11-22 08:31:58 +07:00
a1d22c1daf make auto update is optional 2023-11-22 08:30:43 +07:00
cf7af1ba64 fix ci again again 2023-11-20 08:47:16 +07:00
933ca758ee fix ci again 2023-11-20 08:27:37 +07:00
f537209b92 fix ci 2023-11-20 08:16:39 +07:00
6777610b07 update dependencies 2023-11-20 08:02:09 +07:00
88803cd3cd bump version 2023-11-19 19:51:00 +07:00
Ren Amamiya
6adf5933b0 Merge pull request #113 from luminous-devs/hotfix/themes
Add support dark mode for toaster
2023-11-19 19:49:59 +07:00
9521a49fff support dark mode for toaster 2023-11-19 19:49:26 +07:00
Ren Amamiya
5789a105f5 Merge pull request #112 from luminous-devs/hotfix/settings
Fix settings screen
2023-11-19 15:15:11 +07:00
b7a18bea34 respect user settings 2023-11-19 14:50:59 +07:00
7117ed05a9 update settings screen 2023-11-19 08:48:01 +07:00
c53bdb68e5 add change theme function 2023-11-18 21:17:37 +07:00
6725dca807 fix all dms bugs 2023-11-17 16:03:12 +07:00
Ren Amamiya
077712cf43 Merge pull request #105 from luminous-devs/feat/v2.1.0
v2.1.0
2023-11-17 10:06:58 +07:00
2794c78ee1 add new private key screen 2023-11-17 09:24:21 +07:00
21574023db update dark mode 2023-11-17 08:16:25 +07:00
954b729dc9 wip: settings screen 2023-11-16 17:48:29 +07:00
6dc4e1cde6 bump version 2023-11-16 08:38:23 +07:00
efbdf26706 add window state plugin 2023-11-16 08:25:08 +07:00
b41ec353c6 improve relay connection 2023-11-16 07:59:29 +07:00
875225591a wip: settings screen 2023-11-15 14:20:45 +07:00
04c1223f2e update article screen 2023-11-15 13:32:23 +07:00
773e49afa2 wip 2023-11-15 13:13:11 +07:00
025d7a623b polish 2023-11-15 10:27:47 +07:00
b6caab15e1 update tauri controls 2023-11-15 09:26:59 +07:00
1d3d0a17dc fix updater 2023-11-15 08:32:40 +07:00
1cbe781698 update error screen 2023-11-14 16:14:40 +07:00
dc5b4f8ac1 refactor publish event 2023-11-14 15:15:13 +07:00
fee4ad7b98 smal fixes and update article layout 2023-11-13 15:44:58 +07:00
d5647d7452 redesign loading screen 2023-11-13 08:16:36 +07:00
0a5076f06c polish 2023-11-12 15:43:14 +07:00
a3632571ff polish 2023-11-12 08:41:47 +07:00
5c48ebe103 improve spacing 2023-11-11 16:12:50 +07:00
1c3119577f update widget list 2023-11-11 09:14:23 +07:00
0710996a0d wip: update widget list 2023-11-10 16:05:20 +07:00
0cdf199cb5 wip: rework widget 2023-11-09 18:02:25 +07:00
cb9006abb2 add topic widget 2023-11-09 09:12:42 +07:00
108ecafab7 update widgets 2023-11-08 16:17:47 +07:00
6b030f2902 polish 2023-11-08 08:21:52 +07:00
ce864c8990 wip 2023-11-07 16:23:01 +07:00
ee3e8eb105 wip 2023-11-07 09:35:13 +07:00
701712e7b8 polish 2023-11-05 15:19:51 +07:00
dad388c6ab new parser 2023-11-04 14:18:29 +07:00
Ren Amamiya
912c740c51 Merge pull request #103 from luminous-devs/feat/personal-dashboard
Add personal dashboard
2023-11-03 15:41:28 +07:00
da0b48c5df update personal dashboard screen 2023-11-03 15:38:58 +07:00
64b4745993 update personal screen 2023-11-03 08:45:25 +07:00
8aa2ef39c5 update nip-05 and user profile component styles 2023-11-02 13:47:44 +07:00
a945f04959 update dependencies 2023-11-02 13:09:52 +07:00
c93989237a fix app crash issue 2023-11-01 16:59:13 +07:00
95cf3f60f3 readd updater pubkey 2023-11-01 16:42:20 +07:00
Ren Amamiya
dd21633624 Merge pull request #102 from luminous-devs/feat/v2.0.1
v2.0.1
2023-11-01 15:38:39 +07:00
f01074ea9f fix article 2023-11-01 15:37:50 +07:00
c8d04f4695 support tiff image 2023-11-01 15:24:46 +07:00
2f8aa66ff6 bump version 2023-11-01 15:08:22 +07:00
3ad6830bfb update title bar 2023-11-01 14:58:20 +07:00
f2dddf97f5 polish 2023-11-01 13:01:52 +07:00
e218ebee89 refactor text parser 2023-11-01 10:05:08 +07:00
fd5ecc18a9 refactor all widgets 2023-11-01 08:07:49 +07:00
e7738fb128 refactor newsfeed widget 2023-10-30 16:29:49 +07:00
0b25a4a04b add ndk cache tauri 2023-10-29 11:07:05 +07:00
ace58ecdd5 refactor text parser 2023-10-28 14:36:12 +07:00
6685d9af38 update user component 2023-10-28 08:29:38 +07:00
60b803f419 unify upload function 2023-10-28 07:51:42 +07:00
555c8ec08a migrate to tanstack query v5 2023-10-28 07:35:39 +07:00
42eb882f52 update dependencies 2023-10-27 17:45:08 +07:00
b6784aa979 add missing deps to gh ci 2023-10-26 13:39:11 +07:00
08a7bdc6d5 fix ci again 2023-10-26 13:30:43 +07:00
6fac37a0ca improve follow/unfollow, support petname 2023-10-26 13:29:13 +07:00
37ed0f4892 fix ci 2023-10-26 13:11:06 +07:00
1cb2d8cb41 small fixes 2023-10-26 12:32:03 +07:00
3abce5e6d6 update appicon and tauri config 2023-10-26 12:24:32 +07:00
842f9e14e0 add updater v2 2023-10-26 10:33:27 +07:00
0c8dcef937 ready for alpha 2023-10-26 09:29:33 +07:00
Ren Amamiya
50f81a7d0b Merge pull request #98 from sectore/feat/flake-nix
Add Nix dev environment
2023-10-25 09:23:45 +07:00
dcacf23625 add notification widget 2023-10-25 09:23:20 +07:00
jk
b01af39445 typo + cleanup 2023-10-24 17:26:50 +02:00
507628bcaa update ui for consistent in light and dark mode 2023-10-24 21:15:59 +07:00
jk
4dfea49f71 Nix dev environment 2023-10-24 14:57:15 +02:00
854a47f266 wip 2023-10-24 13:11:10 +07:00
b1a44f2cbf wip: new composer 2023-10-22 15:48:06 +07:00
cade8c8b4c wip: multi-type composer 2023-10-21 15:58:39 +07:00
de88ca51fe update 2023-10-20 15:15:30 +07:00
7c8d8a09fd wip: nsecbunker 2023-10-20 09:36:49 +07:00
e1e54c1a98 update thread widget 2023-10-19 14:45:41 +07:00
0de72eb009 polish 2023-10-19 08:59:50 +07:00
823fb0f239 bump version 2023-10-18 14:59:33 +07:00
Ren Amamiya
3660db4887 Merge pull request #94 from luminous-devs/feat/v2
v2.0.0
2023-10-18 14:55:05 +07:00
b18ae56c36 Merge branch 'main' into feat/v2 2023-10-18 14:53:11 +07:00
939a72f945 ok fine 2023-10-18 14:49:20 +07:00
489ab6bd0b wip: polish 2023-10-18 08:43:31 +07:00
7fa1e89dc8 wip: complete new onboarding 2023-10-17 16:33:41 +07:00
3aa4f294f9 wip: new onboarding 2023-10-16 14:42:19 +07:00
cd3b9ada5a wip: new import account 2023-10-15 16:10:16 +07:00
620e763380 wip: new onboarding 2023-10-14 15:19:49 +07:00
0777c483e5 wip: fix ui for macos 2023-10-13 09:12:30 +07:00
893663561d wip: fix light mode 2023-10-13 08:35:07 +07:00
35650a40f2 wip 2023-10-12 09:13:06 +07:00
Phong
3b46e71525 wip: native secure store 2023-10-11 14:46:35 +07:00
Phong
2fcbf1987b rework macos version 2023-10-11 13:45:56 +07:00
Ren Amamiya
c3f399ea0b rollback to virtua v0.9.1 2023-10-11 08:04:20 +07:00
Ren Amamiya
770a63de63 wip 2023-10-10 15:49:23 +07:00
Ren Amamiya
bc4c3b9803 wip 2023-10-10 11:51:01 +07:00
Ren Amamiya
043c1b1220 wip: update color palette 2023-10-10 08:25:31 +07:00
Ren Amamiya
d20ee26e22 wip: add harmony color palette 2023-10-09 19:05:50 +07:00
Ren Amamiya
8930300fb5 wip 2023-10-09 15:17:15 +07:00
Ren Amamiya
140b8a47bf wip: ui 2023-10-09 11:30:52 +07:00
Ren Amamiya
ced23341d2 wip: new sidebar 2023-10-08 18:01:19 +07:00
Ren Amamiya
0946e9125e wip: ui 2023-10-08 14:14:39 +07:00
Ren Amamiya
bce76bd41c wip: ui 2023-10-08 09:31:11 +07:00
Ren Amamiya
cb91373d33 wip: dark mode - light mode 2023-10-07 09:06:33 +07:00
Ren Amamiya
c71bfb3f6d wip: fully migrate to tauri v2 2023-10-06 09:08:37 +07:00
9627c40d75 wip: tauri v2 2023-10-06 08:30:59 +07:00
Ren Amamiya
1240353e30 wip 2023-10-06 07:40:50 +07:00
Ren Amamiya
cef6b9aca9 wip: new chat layout 2023-10-05 14:55:12 +07:00
Ren Amamiya
508a746578 wip 2023-10-05 07:28:35 +07:00
Ren Amamiya
222ef2ca32 fix: vidstack bug 2023-10-04 20:30:41 +07:00
Ren Amamiya
32843018aa chore(release): v1.2.7 2023-10-04 14:27:48 +07:00
Ren Amamiya
f80dd78a8e wip 2023-10-04 14:11:45 +07:00
Ren Amamiya
9df4835be3 fix ci 2023-10-04 11:17:04 +07:00
Ren Amamiya
480580890e wip: new chat screen 2023-10-04 11:15:10 +07:00
Ren Amamiya
ca57ef1760 Merge branch 'main' into feat/nip28 2023-10-04 07:48:52 +07:00
Ren Amamiya
8e39bca57c fix build 2023-10-04 07:48:20 +07:00
Ren Amamiya
8d9ec0dcfd update config 2023-10-03 18:43:39 +07:00
Ren Amamiya
428d52f175 wip 2023-10-03 16:24:09 +07:00
Ren Amamiya
cdeb5afd28 upgrade to tauri v1.5.1 2023-10-03 07:38:23 +07:00
Ren Amamiya
1f3ba09cec new app icon 2023-10-02 15:36:20 +07:00
Ren Amamiya
4915b833e7 small fixed 2023-10-01 15:00:38 +07:00
Ren Amamiya
674e5f0339 finish relay manegament screen 2023-10-01 09:02:14 +07:00
Ren Amamiya
11ed618a7f wip: relay manegament screen 2023-09-30 19:07:17 +07:00
Ren Amamiya
a2e3247432 wip 2023-09-30 15:12:33 +07:00
Ren Amamiya
09b3eeda99 small fixes and bump version 2023-09-29 12:40:02 +07:00
Ren Amamiya
700f3eb85f upgrade to tauri v1.5.0 2023-09-29 09:19:40 +07:00
Ren Amamiya
2f87ed8949 polish widget code 2023-09-29 09:11:38 +07:00
Ren Amamiya
cb3c95b133 clean up and improve perf 2023-09-28 16:18:04 +07:00
Ren Amamiya
4f4e2f5ccd wip: multi account 2023-09-28 14:00:52 +07:00
Ren Amamiya
0e6fc65b08 Merge pull request #91 from luminous-devs/feat/ui-patch
v1.2.6
2023-09-28 08:28:34 +07:00
Ren Amamiya
876d351358 update dependencies and polish 2023-09-28 08:22:38 +07:00
Ren Amamiya
c80414a72d small fixes and support $ boost sign 2023-09-28 07:29:05 +07:00
Ren Amamiya
7cef6efa6f fix crash on windows 2023-09-27 18:24:58 +07:00
Ren Amamiya
74ff49b8db fix app window is not resizable 2023-09-27 16:11:33 +07:00
Ren Amamiya
2b50fc438f improve startup time 2023-09-27 14:53:01 +07:00
Ren Amamiya
b339e842ca perf improve 2023-09-27 08:32:19 +07:00
Ren Amamiya
1d93f8cf6a clean up 2023-09-26 09:40:02 +07:00
Ren Amamiya
236131087a polish 2023-09-26 09:05:39 +07:00
Ren Amamiya
a66770989b wip: finish browse users 2023-09-25 14:35:47 +07:00
Ren Amamiya
9ff74599eb customize traffic light on macOS 2023-09-25 07:43:13 +07:00
Ren Amamiya
c049fa8865 wip: update browse user screen 2023-09-24 15:42:49 +07:00
Ren Amamiya
41b12746a7 wip 2023-09-24 09:13:42 +07:00
Ren Amamiya
50f90ddcc2 wip 2023-09-24 07:55:27 +07:00
Ren Amamiya
c9bc7b81dd wip: browse user 2023-09-22 14:13:55 +07:00
Ren Amamiya
18a9ba3fb0 wip: replace ndk cache with metadata table 2023-09-21 16:11:35 +07:00
Ren Amamiya
413571ee7f wip 2023-09-21 15:28:01 +07:00
Ren Amamiya
17fe3bb1f6 wip: timeline 2023-09-21 09:11:45 +07:00
Ren Amamiya
0e5adb246f resizable widget 2023-09-20 14:31:14 +07:00
Ren Amamiya
296136203a update dependencies 2023-09-20 08:22:02 +07:00
Ren Amamiya
1bbfebc2b8 fix ci again 2023-09-19 16:31:38 +07:00
Ren Amamiya
d84e97b0d4 fix ci 2023-09-19 15:59:50 +07:00
Ren Amamiya
824aa8fa28 back to pnpm, bun is fun but cannot generate tauri build 2023-09-19 15:56:33 +07:00
Ren Amamiya
2b34ef3b7a fix build error 2023-09-19 15:29:26 +07:00
Ren Amamiya
4fa8f40e6a improve nwc 2023-09-19 15:06:12 +07:00
Ren Amamiya
c1bddeb6ed bump version 2023-09-19 11:20:15 +07:00
Ren Amamiya
5c2bfa0ea3 improve notification 2023-09-19 11:15:35 +07:00
Ren Amamiya
60e93965ea respect user's relay list (kind 10002) 2023-09-19 08:01:57 +07:00
Ren Amamiya
380d1fb930 temporary using default relays 2023-09-18 15:42:17 +07:00
Ren Amamiya
53aa13c8aa clean up messy code 2023-09-18 09:50:15 +07:00
Ren Amamiya
13f5190ba1 update dependencies and better handle repost 2023-09-17 16:14:04 +07:00
Ren Amamiya
c590e290e0 Merge pull request #87 from luminous-devs/feat/improve-onboarding
merge now, improve later
2023-09-17 08:44:16 +07:00
Ren Amamiya
cdf86a2613 polish 2023-09-17 08:43:42 +07:00
Ren Amamiya
8726e22b38 replace nostr.com with njump.me 2023-09-17 08:03:29 +07:00
Ren Amamiya
1206486016 partial support replaceable event 2023-09-16 16:06:01 +07:00
Ren Amamiya
11ad281d72 wip 2023-09-16 08:57:24 +07:00
Ren Amamiya
fe4bfa1699 wip: learn nostr widget 2023-09-16 07:47:44 +07:00
Ren Amamiya
c6a0636e8c add complete screen 2023-09-15 10:29:39 +07:00
Ren Amamiya
d3db6492d9 update onboarding 2023-09-15 08:58:09 +07:00
Ren Amamiya
8f8617d8f9 update import key flow 2023-09-14 16:51:38 +07:00
Ren Amamiya
8e513404c3 update create account flow 2023-09-14 09:20:36 +07:00
Ren Amamiya
5a6dd172b1 small fixes 2023-09-13 11:10:24 +07:00
Ren Amamiya
fa0d7cac31 hide nwc secret in frontend 2023-09-12 16:16:57 +07:00
Ren Amamiya
432b2ae185 polish nwc connection flow 2023-09-12 16:00:41 +07:00
Ren Amamiya
fb8a6581dd replace pnpm with bun 2023-09-12 08:43:12 +07:00
Ren Amamiya
a4f221f868 wip: redesign nwc 2023-09-12 08:27:29 +07:00
Ren Amamiya
602d012efe fix alby connection 2023-09-11 07:52:43 +07:00
Ren Amamiya
5bf816eba2 fully suport alby nostr-wallet-connect 2023-09-10 16:25:35 +07:00
Ren Amamiya
a33c9d3517 wip: integrate alby 2023-09-10 07:19:36 +07:00
683 changed files with 47151 additions and 21620 deletions

44
.dockerignore Normal file
View File

@@ -0,0 +1,44 @@
# Dependencies
**/node_modules
.pnp
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
**/.next/
**/out/
**/build
**/dist
**/target
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem
# Unnecessary files
**/.git/
.github/
flatpak/*.xml
flatpak/*.desktop
flatpak/*.yml

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

View File

@@ -1,3 +0,0 @@
/**/node_modules/*
node_modules/
dist/

View File

@@ -1,49 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
env: {
browser: true,
amd: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:jsx-a11y/recommended',
'prettier'
],
plugins: [],
rules: {
'react/react-in-jsx-scope': 'off',
'jsx-a11y/accessible-emoji': 'off',
'react/prop-types': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'jsx-a11y/anchor-is-valid': [
'error',
{
components: ['Link'],
specialLink: ['hrefLeft', 'hrefRight'],
aspects: ['invalidHref', 'preferButton'],
},
],
},
};

72
.github/workflows/flatpak-bundle.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
name: Flatpak
on: workflow_dispatch
jobs:
prepare-repo:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: cache of container
id: cache-container
uses: actions/cache@v3
with:
path: prepare-dist
key: ${{ runner.os }}-container-${{ hashFiles('prepare-dist') }}
- name: Run latest-tag
id: latest-tag
uses: oprypin/find-latest-tag@v1
with:
repository:
lumehq/lume
#FIXME: lumehq after merged fix, now it just won't find tags
# repository: ${{ github.repository }}
- name: Build container
# if: steps.cache-container.outputs.cache-hit != 'true'
run: |
docker buildx build -t flatpak-prepare-lume --build-arg=${{steps.latest-tag.outputs.tag}} --rm --output=. --target=final -f flatpak/Containerfile .
- name: Copy flatpak files content
run: |
cp -r flatpak/*.xml flatpak/*.desktop flatpak/*.yml prepare-dist
- uses: actions/upload-artifact@v4
with:
name: repo-dist
path: prepare-dist
flatpak:
name: flatpak-bundle
needs: prepare-repo
runs-on: ubuntu-latest
container:
image: bilelmoussaoui/flatpak-github-actions:gnome-45
options: --privileged
steps:
- uses: actions/download-artifact@v4
with:
name: repo-dist
- uses: actions/checkout@v4
with:
repository: flathub/shared-modules
path: shared-modules
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
bundle: lume.flatpak
manifest-path: nu.lume.Lume.yml
restore-cache: false
# cache-key: flatpak-builder-${{ github.sha }}
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
append_body: true
files: lume.flatpak
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: geekyeggo/delete-artifact@v4
with:
name: repo-dist
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,69 +1,71 @@
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: '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: 18
- 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 libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev 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 }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: v__VERSION__
releaseName: 'App 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 }}
includeDebug: true
args: ${{ matrix.args }}
includeDebug: false

61
.gitignore vendored
View File

@@ -1,31 +1,38 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
node_modules
dist
dist-ssr
out
*.local
.next
.vscode
pnpm-lock.yaml
*.db
*.db-journal
.pnp
.pnp.js
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage/
# Turbo
.turbo/
# Vercel
.vercel/
# Build Outputs
.next/
out/
build/
dist/
# Debug
*.log.*
# Misc
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/.gtm/
*.pem
.vscode/
ndb/

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm exec lint-staged

View File

@@ -1,9 +0,0 @@
.tmp
.cache/
coverage/
.nyc_output/
**/.yarn/**
**/.pnp.*
/dist*/
node_modules/
src-tauri/

View File

@@ -1,22 +0,0 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"tabWidth": 2,
"printWidth": 90,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"importOrder": [
"^@app/(.*)$",
"^@libs/(.*)$",
"^@shared/(.*)$",
"^@stores/(.*)$",
"^@utils/(.*)$",
"^[./]"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"],
"pluginSearchDirs": false
}

View File

@@ -1,19 +1,35 @@
### Introduction
_Note_: Lume is under rewrite to using Rust Nostr as back-end and more lightweight front-end. If you need stable version, you can download v3 and below.
Lume is a nostr client
Source code for v3 is stored here: https://github.com/lumehq/lume/tree/old
### Usage
--
Download Lume for your platform here: [https://github.com/luminous-devs/lume/releases](https://github.com/luminous-devs/lume/releases)
## Introduction
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
## Usage
Download Lume v3 (v3.0.1-stable) for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
Supported platform: macOS, Windows and Linux
### Develop
## Prerequisites
- Node.js >= 18: https://nodejs.org/en
- Rust: https://rustup.rs/
- PNPM: https://pnpm.io
- Tauri v2: https://beta.tauri.app/guides/prerequisites/
## Develop
Clone project
```
git clone https://github.com/luminous-devs/lume.git && cd lume
git clone https://github.com/lumehq/lume.git && cd lume
```
Install packages
@@ -22,20 +38,33 @@ Install packages
pnpm install
```
Run dev
Run dev build
```
pnpm tauri dev
```
Build
Generate production build
```
pnpm tauri build
```
(Advance) - Generate SQLite migration
## Nix
```
pnpm add-migrate <migrate_name>
```
Requirements:
1. [Install Nix](https://zero-to-flakes.com/install)
1. [Setup `direnv`](https://zero-to-flakes.com/direnv)
`cd` into the root folder of the project to enter `nix develop` shell. Run `direnv allow` (only once). Then run `pnpm` or `bun` (experimental) commands as described above.
## License
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 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 General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.

26
apps/desktop2/.gitignore vendored Normal file
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
apps/desktop2/index.html Normal file
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>

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": {
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^",
"@lume/ui": "workspace:^",
"@lume/utils": "workspace:^",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/query-sync-storage-persister": "^5.32.0",
"@tanstack/react-query": "^5.32.0",
"@tanstack/react-query-persist-client": "^5.32.0",
"@tanstack/react-router": "1.29.2",
"i18next": "^23.11.3",
"i18next-resources-to-backend": "^1.2.1",
"minidenticons": "^4.2.1",
"nanoid": "^5.0.7",
"nostr-tools": "^2.5.1",
"react": "^18.3.1",
"react-currency-input-field": "^3.8.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.3",
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.1",
"slate": "^0.102.0",
"slate-react": "^0.102.0",
"sonner": "^1.4.41",
"use-debounce": "^10.0.0",
"virtua": "^0.30.2"
},
"devDependencies": {
"@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@tanstack/router-devtools": "^1.31.3",
"@tanstack/router-vite-plugin": "^1.30.0",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.6.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.10",
"vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.2"
}
}

View File

BIN
apps/desktop2/public/ai.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 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: 21 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: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

122
apps/desktop2/src/app.css Normal file
View File

@@ -0,0 +1,122 @@
@tailwind base;
@tailwind utilities;
@tailwind components;
*::-webkit-scrollbar {
@apply w-[5px];
}
*::-webkit-scrollbar-track {
@apply bg-transparent;
}
*::-webkit-scrollbar-thumb {
@apply rounded bg-black dark:bg-white;
}
@layer utilities {
.content-break {
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
}
.shadow-toolbar {
box-shadow:
0 0 #0000,
0 0 #0000,
0 8px 24px 0 rgba(0, 0, 0, 0.2),
0 2px 8px 0 rgba(0, 0, 0, 0.08),
inset 0 0 0 1px rgba(0, 0, 0, 0.2),
inset 0 0 0 2px hsla(0, 0%, 100%, 0.14);
}
.shadow-primary {
box-shadow: 0px 0px 4px rgba(66, 65, 73, 0.14);
}
}
/*
Overide some default styles
*/
html {
font-size: 14px;
}
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;
}
.spinner-leaf {
position: absolute;
top: 0;
left: calc(50% - 12.5% / 2);
width: 12.5%;
height: 100%;
animation: spinner-leaf-fade 800ms linear infinite;
&::before {
content: "";
display: block;
width: 100%;
height: 30%;
background-color: currentColor;
@apply rounded;
}
&:where(:nth-child(1)) {
transform: rotate(0deg);
animation-delay: -800ms;
}
&:where(:nth-child(2)) {
transform: rotate(45deg);
animation-delay: -700ms;
}
&:where(:nth-child(3)) {
transform: rotate(90deg);
animation-delay: -600ms;
}
&:where(:nth-child(4)) {
transform: rotate(135deg);
animation-delay: -500ms;
}
&:where(:nth-child(5)) {
transform: rotate(180deg);
animation-delay: -400ms;
}
&:where(:nth-child(6)) {
transform: rotate(225deg);
animation-delay: -300ms;
}
&:where(:nth-child(7)) {
transform: rotate(270deg);
animation-delay: -200ms;
}
&:where(:nth-child(8)) {
transform: rotate(315deg);
animation-delay: -100ms;
}
}
@keyframes spinner-leaf-fade {
from {
opacity: 1;
}
to {
opacity: 0.25;
}
}

72
apps/desktop2/src/app.tsx Normal file
View File

@@ -0,0 +1,72 @@
import { Ark } from "@lume/ark";
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { platform } from "@tauri-apps/plugin-os";
import React, { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import { Toaster } from "sonner";
import "./app.css";
import i18n from "./locale";
import { routeTree } from "./router.gen"; // auto generated file
const ark = new Ark();
const queryClient = new QueryClient();
const platformName = await platform();
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
// Set up a Router instance
const router = createRouter({
routeTree,
context: {
ark,
queryClient,
platform: platformName,
},
});
// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
function App() {
return <RouterProvider router={router} />;
}
// biome-ignore lint/style/noNonNullAssertion: idk
const rootElement = document.getElementById("root")!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
>
<StrictMode>
<Toaster
position="bottom-right"
icons={{
success: <CheckCircleIcon className="size-5" />,
info: <InfoCircleIcon className="size-5" />,
error: <CancelCircleIcon className="size-5" />,
}}
closeButton
theme="system"
/>
<App />
</StrictMode>
</PersistQueryClientProvider>
</I18nextProvider>,
);
}

View File

@@ -0,0 +1,47 @@
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import {
type Dispatch,
type ReactNode,
type SetStateAction,
useState,
} from "react";
import { toast } from "sonner";
export function AvatarUploader({
setPicture,
children,
className,
}: {
setPicture: Dispatch<SetStateAction<string>>;
children: ReactNode;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {
// start loading
setLoading(true);
try {
const image = await ark.upload();
setPicture(image);
} catch (e) {
toast.error(String(e));
}
// stop loading
setLoading(false);
};
return (
<button
type="button"
onClick={() => uploadAvatar()}
className={cn("size-4", className)}
>
{loading ? <Spinner className="size-4" /> : children}
</button>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
import type { Event } from "@lume/types";
import { Note } from "@lume/ui";
import { cn } from "@lume/utils";
export function Notification({
event,
className,
}: {
event: Event;
className?: string;
}) {
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
</div>
<Note.Content className="px-3" />
</div>
<div className="flex items-center h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

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

View File

@@ -0,0 +1,83 @@
import type { Event } from "@lume/types";
import { Note, Spinner, User } from "@lume/ui";
import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
export function RepostNote({
event,
className,
}: {
event: Event;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const {
isLoading,
isError,
data: repostEvent,
} = useQuery({
queryKey: ["repost", event.id],
queryFn: async () => {
try {
if (event.content.length > 50) {
const embed: Event = JSON.parse(event.content);
return embed;
}
const id = event.tags.find((el) => el[0] === "e")?.[1];
const repostEvent = await ark.get_event(id);
return repostEvent;
} catch (e) {
throw new Error(e);
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
});
return (
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex items-center gap-2 px-3 py-3 border-b border-neutral-100 dark:border-neutral-800/50 rounded-t-xl">
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Reposted by
</div>
<User.Avatar className="size-6 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
</User.Root>
</User.Provider>
{isLoading ? (
<div className="flex h-20 items-center justify-center gap-2">
<Spinner />
Loading event...
</div>
) : isError || !repostEvent ? (
<div className="flex h-20 items-center justify-center">
Event not found within your current relay set
</div>
) : (
<Note.Provider event={repostEvent}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</Note.Root>
</Note.Provider>
)}
</Note.Root>
);
}

View File

@@ -0,0 +1,34 @@
import type { Event } from "@lume/types";
import { Note } from "@lume/ui";
import { cn } from "@lume/utils";
export function TextNote({
event,
className,
}: {
event: Event;
className?: string;
}) {
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

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

View File

@@ -0,0 +1,23 @@
import { resolveResource } from "@tauri-apps/api/path";
import { readTextFile } from "@tauri-apps/plugin-fs";
import i18n from "i18next";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next";
i18n
.use(
resourcesToBackend(async (language: string) => {
const file_path = await resolveResource(`locales/${language}.json`);
return JSON.parse(await readTextFile(file_path));
}),
)
.use(initReactI18next)
.init({
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
export default i18n;

View File

@@ -0,0 +1,182 @@
import { Col } from "@/components/col";
import { Toolbar } from "@/components/toolbar";
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import type { EventColumns, LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { resolveResource } from "@tauri-apps/api/path";
import { getCurrent } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { nanoid } from "nanoid";
import { useEffect, useRef, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import { VList, type VListHandle } from "virtua";
export const Route = createFileRoute("/$account/home")({
beforeLoad: async ({ context }) => {
try {
const ark = context.ark;
const resourcePath = await resolveResource(
"resources/system_columns.json",
);
const systemColumns: LumeColumn[] = JSON.parse(
await readTextFile(resourcePath),
);
const userColumns = await ark.get_columns();
return {
storedColumns: !userColumns.length ? systemColumns : userColumns,
};
} catch (e) {
console.error(String(e));
}
},
component: Screen,
});
function Screen() {
const vlistRef = useRef<VListHandle>(null);
const { account } = Route.useParams();
const { ark, storedColumns } = Route.useRouteContext();
const [selectedIndex, setSelectedIndex] = useState(-1);
const [columns, setColumns] = useState(storedColumns);
const [isScroll, setIsScroll] = useState(false);
const [isResize, setIsResize] = useState(false);
const goLeft = () => {
const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex);
vlistRef.current.scrollToIndex(prevIndex, {
align: "center",
});
};
const goRight = () => {
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
setSelectedIndex(nextIndex);
vlistRef.current.scrollToIndex(nextIndex, {
align: "center",
});
};
const add = useDebouncedCallback((column: LumeColumn) => {
// update col label
column.label = `${column.label}-${nanoid()}`;
// create new cols
const cols = [...columns];
const openColIndex = cols.findIndex((col) => col.label === "open");
const newCols = [
...cols.slice(0, openColIndex),
column,
...cols.slice(openColIndex),
];
setColumns(newCols);
setSelectedIndex(newCols.length);
setIsScroll(true);
// scroll to the newest column
vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "end",
});
}, 150);
const remove = useDebouncedCallback((label: string) => {
const newCols = columns.filter((t) => t.label !== label);
setColumns(newCols);
setSelectedIndex(newCols.length);
setIsScroll(true);
// scroll to the first column
vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "start",
});
}, 150);
const updateName = useDebouncedCallback((label: string, title: string) => {
const currentColIndex = columns.findIndex((col) => col.label === label);
const updatedCol = Object.assign({}, columns[currentColIndex]);
updatedCol.name = title;
const newCols = columns.slice();
newCols[currentColIndex] = updatedCol;
setColumns(newCols);
}, 150);
const startResize = useDebouncedCallback(
() => setIsResize((prev) => !prev),
150,
);
useEffect(() => {
// save state
ark.set_columns(columns);
}, [columns]);
useEffect(() => {
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
if (data.payload.type === "add") add(data.payload.column);
if (data.payload.type === "remove") remove(data.payload.label);
if (data.payload.type === "set_title")
updateName(data.payload.label, data.payload.title);
});
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
startResize();
});
return () => {
unlistenColEvent.then((f) => f());
unlistenWindowResize.then((f) => f());
};
}, []);
return (
<div className="h-full w-full">
<VList
ref={vlistRef}
horizontal
tabIndex={-1}
itemSize={440}
overscan={3}
onScroll={() => setIsScroll(true)}
onScrollEnd={() => setIsScroll(false)}
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
>
{columns.map((column) => (
<Col
key={column.label}
column={column}
account={account}
isScroll={isScroll}
isResize={isResize}
/>
))}
</VList>
<Toolbar>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => goLeft()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowLeftIcon className="size-5" />
</button>
<button
type="button"
onClick={() => goRight()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowRightIcon className="size-5" />
</button>
</div>
</Toolbar>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import { BellIcon, ComposeFilledIcon, PlusIcon, SearchIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types";
import { User } from "@lume/ui";
import {
cn,
decodeZapInvoice,
displayNpub,
sendNativeNotification,
} from "@lume/utils";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useState } from "react";
export const Route = createFileRoute("/$account")({
beforeLoad: async ({ context }) => {
const ark = context.ark;
const accounts = await ark.get_all_accounts();
return { accounts };
},
component: Screen,
});
function Screen() {
const { ark, platform } = Route.useRouteContext();
const navigate = Route.useNavigate();
return (
<div className="flex h-screen w-screen flex-col">
<div
data-tauri-drag-region
className={cn(
"flex h-11 shrink-0 items-center justify-between pr-2",
platform === "macos" ? "ml-2 pl-20" : "pl-4",
)}
>
<div className="flex items-center gap-3">
<Accounts />
<button
type="button"
onClick={() => navigate({ to: "/landing" })}
className="inline-flex size-8 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
>
<PlusIcon className="size-5" />
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => ark.open_editor()}
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
>
<ComposeFilledIcon className="size-4" />
New Post
</button>
<Bell />
<button
type="button"
onClick={() => ark.open_search()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<SearchIcon className="size-5" />
</button>
<div id="toolbar" />
</div>
</div>
<div className="flex-1">
<Outlet />
</div>
</div>
);
}
function Accounts() {
const navigate = Route.useNavigate();
const { ark, accounts } = Route.useRouteContext();
const { account } = Route.useParams();
const changeAccount = async (npub: string) => {
if (npub === account) return;
const select = await ark.load_selected_account(npub);
if (select) {
return navigate({ to: "/$account/home", params: { account: npub } });
}
};
return (
<div data-tauri-drag-region className="flex items-center gap-3">
{accounts.map((user) => (
<button key={user} type="button" onClick={() => changeAccount(user)}>
<User.Provider pubkey={user}>
<User.Root
className={cn(
"rounded-full",
user === account
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
: "",
)}
>
<User.Avatar
className={cn(
"aspect-square h-auto rounded-full object-cover",
user === account ? "w-7" : "w-8",
)}
/>
</User.Root>
</User.Provider>
</button>
))}
</div>
);
}
function Bell() {
const { ark } = Route.useRouteContext();
const { account } = Route.useParams();
const [count, setCount] = useState(0);
useEffect(() => {
const unlisten = getCurrent().listen<string>(
"activity",
async (payload) => {
setCount((prevCount) => prevCount + 1);
await invoke("set_badge", { count });
const event: Event = JSON.parse(payload.payload);
const user = await ark.get_profile(event.pubkey);
const userName =
user.display_name || user.name || displayNpub(event.pubkey, 16);
switch (event.kind) {
case Kind.Text: {
sendNativeNotification("Mentioned you in a note", userName);
break;
}
case Kind.Repost: {
sendNativeNotification("Reposted your note", userName);
break;
}
case Kind.ZapReceipt: {
const amount = decodeZapInvoice(event.tags);
sendNativeNotification(
`Zapped ₿ ${amount.bitcoinFormatted}`,
userName,
);
break;
}
default:
break;
}
},
);
return () => {
unlisten.then((f) => f());
};
}, []);
return (
<button
type="button"
onClick={() => {
setCount(0);
ark.open_activity(account);
}}
className="relative inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<BellIcon className="size-5" />
{count > 0 ? (
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
) : null}
</button>
);
}

View File

@@ -0,0 +1,45 @@
import type { Ark } from "@lume/ark";
import type { Interests, Metadata, Settings } from "@lume/types";
import { Spinner } from "@lume/ui";
import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import type { Platform } from "@tauri-apps/plugin-os";
import type { Descendant } from "slate";
type EditorElement = {
type: string;
children: Descendant[];
eventId?: string;
};
interface RouterContext {
// System
ark: Ark;
queryClient: QueryClient;
// App info
platform?: Platform;
locale?: string;
// Settings
settings?: Settings;
interests?: Interests;
// Profile
accounts?: string[];
profile?: Metadata;
isNewUser?: boolean;
// Editor
initialValue?: EditorElement[];
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
pendingComponent: Pending,
wrapInSuspense: true,
});
function Pending() {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center">
<Spinner className="size-5" />
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/activity/$account/messages")({
component: () => <div>Hello /activity/$account/messages!</div>,
});

View File

@@ -0,0 +1,60 @@
import { Note, Spinner } from "@lume/ui";
import { Await, createFileRoute, defer } from "@tanstack/react-router";
import { Suspense } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/activity/$account/texts")({
loader: async ({ context, params }) => {
const ark = context.ark;
return { data: defer(ark.get_activities(params.account, "1")) };
},
component: Screen,
});
function Screen() {
const { data } = Route.useLoaderData();
return (
<Virtualizer overscan={3}>
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<Spinner className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(events) =>
events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
>
<Note.Provider event={event}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.Activity className="px-3" />
<Note.Content className="px-3" quote={false} clean />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
</div>
))
}
</Await>
</Suspense>
</Virtualizer>
);
}

View File

@@ -0,0 +1,50 @@
import { Box, Container } from "@lume/ui";
import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/activity/$account")({
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
return (
<Container withDrag withNavigate={false}>
<Box className="scrollbar-none shadow-none bg-black/5 dark:bg-white/5 backdrop-blur-sm flex flex-col overflow-y-auto">
<div className="h-14 shrink-0 flex w-full items-center gap-1 px-3">
<div className="inline-flex h-full w-full items-center gap-1">
<Link to="/activity/$account/texts" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
Notes
</div>
)}
</Link>
<Link to="/activity/$account/zaps" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
Zaps
</div>
)}
</Link>
</div>
</div>
<div className="px-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
<Outlet />
</div>
</Box>
</Container>
);
}

View File

@@ -0,0 +1,64 @@
import { Note, Spinner, User } from "@lume/ui";
import { decodeZapInvoice } from "@lume/utils";
import { Await, createFileRoute, defer } from "@tanstack/react-router";
import { Suspense } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/activity/$account/zaps")({
loader: async ({ context, params }) => {
const ark = context.ark;
return { data: defer(ark.get_activities(params.account, "9735")) };
},
component: Screen,
});
function Screen() {
const { data } = Route.useLoaderData();
return (
<Virtualizer overscan={3}>
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<Spinner className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(events) =>
events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex flex-col">
<div className="text-lg h-20 font-medium leading-tight flex w-full items-center justify-center">
{decodeZapInvoice(event.tags).bitcoinFormatted}
</div>
<div className="h-11 border-t border-neutral-100 dark:border-neutral-900 flex items-center gap-1 px-2">
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-7 rounded-full shrink-0" />
<User.Name className="text-sm font-medium" />
</div>
<div className="text-sm text-neutral-700 dark:text-neutral-300">
zapped you
</div>
</div>
</User.Root>
</User.Provider>
</div>
))
}
</Await>
</Suspense>
</Virtualizer>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/privkey")({
component: Screen,
});
function Screen() {
const { ark } = Route.useRouteContext();
const navigate = Route.useNavigate();
const [key, setKey] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!key.startsWith("nsec1"))
return toast.warning(
"You need to enter a valid private key starts with nsec or ncryptsec",
);
try {
setLoading(true);
const npub = await ark.save_account(key, password);
if (npub) {
navigate({
to: "/auth/settings",
search: { account: npub },
replace: true,
});
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="text-center">
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
</div>
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="key"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Private Key
</label>
<input
name="key"
type="text"
placeholder="nsec or ncryptsec..."
value={key}
onChange={(e) => setKey(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="password"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Password (Optional)
</label>
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : "Login"}
</button>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,218 @@
import { LaurelIcon } from "@lume/icons";
import type { AppRouteSearch, Settings } from "@lume/types";
import { Spinner } from "@lume/ui";
import * as Switch from "@radix-ui/react-switch";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/settings")({
validateSearch: (search: Record<string, string>): AppRouteSearch => {
return {
account: search.account,
};
},
beforeLoad: async ({ context }) => {
const permissionGranted = await isPermissionGranted(); // get notification permission
const ark = context.ark;
const settings = await ark.get_settings();
return {
settings: { ...settings, notification: permissionGranted },
};
},
component: Screen,
pendingComponent: Pending,
});
function Screen() {
const navigate = useNavigate();
const { account } = Route.useSearch();
const { t } = useTranslation();
const { ark, settings } = Route.useRouteContext();
const [newSettings, setNewSettings] = useState<Settings>(settings);
const [loading, setLoading] = useState(false);
const toggleNofitication = async () => {
await requestPermission();
setNewSettings((prev) => ({
...prev,
notification: !newSettings.notification,
}));
};
const toggleAutoUpdate = () => {
setNewSettings((prev) => ({
...prev,
autoUpdate: !newSettings.autoUpdate,
}));
};
const toggleEnhancedPrivacy = () => {
setNewSettings((prev) => ({
...prev,
enhancedPrivacy: !newSettings.enhancedPrivacy,
}));
};
const toggleZap = () => {
setNewSettings((prev) => ({
...prev,
zap: !newSettings.zap,
}));
};
const toggleNsfw = () => {
setNewSettings((prev) => ({
...prev,
nsfw: !newSettings.nsfw,
}));
};
const submit = async () => {
try {
// start loading
setLoading(true);
// publish settings
const eventId = await ark.set_settings(newSettings);
if (eventId) {
return navigate({
to: "/$account/home",
params: { account },
replace: true,
});
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col items-center gap-5 text-center">
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 dark:bg-teal-950 text-teal-500">
<LaurelIcon className="size-8" />
</div>
<div>
<h1 className="text-xl font-semibold">
{t("onboardingSettings.title")}
</h1>
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
{t("onboardingSettings.subtitle")}
</p>
</div>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Push Notification</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Enabling push notifications will allow you to receive
notifications from Lume.
</p>
</div>
<Switch.Root
checked={newSettings.notification}
onClick={() => toggleNofitication()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-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-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Enhanced Privacy</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Lume will display external resources like image, video or link
preview as plain text.
</p>
</div>
<Switch.Root
checked={newSettings.enhancedPrivacy}
onClick={() => toggleEnhancedPrivacy()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-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-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Auto Update</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically download and install new version.
</p>
</div>
<Switch.Root
checked={newSettings.autoUpdate}
onClick={() => toggleAutoUpdate()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-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-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Zap</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Show the Zap button in each note and user's profile screen, use
for send Bitcoin tip to other users.
</p>
</div>
<Switch.Root
checked={newSettings.zap}
onClick={() => toggleZap()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-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-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Filter sensitive content</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
By default, Lume will display all content which have Content
Warning tag, it's may include NSFW content.
</p>
</div>
<Switch.Root
checked={newSettings.nsfw}
onClick={() => toggleNsfw()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-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>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{t("global.continue")}
</button>
</div>
</div>
);
}
function Pending() {
return (
<div className="flex h-full w-full items-center justify-center">
<button type="button" className="size-5" disabled>
<Spinner className="size-5" />
</button>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { CheckCircleIcon } from "@lume/icons";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner, User } from "@lume/ui";
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/create-group")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
loader: async ({ context }) => {
const ark = context.ark;
const contacts = await ark.get_contact_list();
return contacts;
},
component: Screen,
});
function Screen() {
const contacts = Route.useLoaderData();
const router = useRouter();
const { ark } = Route.useRouteContext();
const { label, redirect } = Route.useSearch();
const [title, setTitle] = useState<string>("Just a new group");
const [users, setUsers] = useState<Array<string>>([]);
const [loading, setLoading] = useState(false);
const [isDone, setIsDone] = useState(false);
const toggleUser = (pubkey: string) => {
const arr = users.includes(pubkey)
? users.filter((i) => i !== pubkey)
: [...users, pubkey];
setUsers(arr);
};
const submit = async () => {
try {
if (isDone) return router.history.push(redirect);
// start loading
setLoading(true);
const groups = await ark.set_nstore(
`lume_group_${label}`,
JSON.stringify(users),
);
if (groups) {
toast.success("Group has been created successfully.");
// start loading
setIsDone(true);
setLoading(false);
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="h-full overflow-y-auto scrollbar-none">
<div className="flex flex-col gap-5 p-3">
<div className="flex flex-col gap-1">
<label htmlFor="name" className="font-medium">
Name
</label>
<input
name="name"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Nostrichs..."
className="h-10 rounded-lg bg-transparent border border-neutral-300 dark:border-neutral-700 px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<div className="inline-flex items-center justify-between">
<span className="font-medium">Pick user</span>
<span className="text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span>
</div>
<div className="flex flex-col gap-2">
{contacts.map((item: string) => (
<button
key={item}
type="button"
onClick={() => toggleUser(item)}
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20"
>
<User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5">
<User.Avatar className="size-10 rounded-full object-cover" />
<div className="flex items-center gap-1">
<User.Name className="font-medium" />
<User.NIP05 />
</div>
</User.Root>
</User.Provider>
{users.includes(item) ? (
<CheckCircleIcon className="size-5 text-teal-500" />
) : null}
</button>
))}
</div>
</div>
</div>
<div className="fixed z-10 flex items-center justify-center w-full bottom-6">
{users.length >= 1 ? (
<button
type="button"
onClick={() => submit()}
disabled={users.length < 1}
className="inline-flex items-center justify-center px-4 font-medium text-white transform bg-blue-500 rounded-full active:translate-y-1 w-32 h-10 hover:bg-blue-600 focus:outline-none"
>
{isDone ? "Back" : loading ? <Spinner /> : "Update"}
</button>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { AddMediaIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import { cn, insertImage, isImagePath } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useState } from "react";
import { useSlateStatic } from "slate-react";
import { toast } from "sonner";
export function MediaButton({ className }: { className?: string }) {
const editor = useSlateStatic();
const { ark } = useRouteContext({ strict: false });
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => {
try {
// start loading
setLoading(true);
const image = await ark.upload();
insertImage(editor, image);
// reset loading
setLoading(false);
} catch (e) {
setLoading(false);
toast.error(`Upload failed, error: ${e}`);
}
};
useEffect(() => {
let unlisten: UnlistenFn = undefined;
async function listenFileDrop() {
const window = getCurrent();
if (!unlisten) {
unlisten = await window.listen("tauri://file-drop", async (event) => {
// @ts-ignore, lfg !!!
const items: string[] = event.payload.paths;
// start loading
setLoading(true);
// upload all images
for (const item of items) {
if (isImagePath(item)) {
const image = await ark.upload(item);
insertImage(editor, image);
}
}
// stop loading
setLoading(false);
});
}
}
listenFileDrop();
return () => {
if (unlisten) unlisten();
};
}, []);
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => uploadToNostrBuild()}
disabled={loading}
className={cn("inline-flex items-center justify-center", className)}
>
{loading ? (
<Spinner className="size-4" />
) : (
<AddMediaIcon className="size-4" />
)}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Upload media
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,83 @@
import { MentionIcon } from "@lume/icons";
import { cn, insertMention } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useEffect, useState } from "react";
import { useRouteContext } from "@tanstack/react-router";
import { User } from "@lume/ui";
import { useSlateStatic } from "slate-react";
import type { Contact } from "@lume/types";
import { toast } from "sonner";
export function MentionButton({ className }: { className?: string }) {
const editor = useSlateStatic();
const { ark } = useRouteContext({ strict: false });
const [contacts, setContacts] = useState<string[]>([]);
const select = async (user: string) => {
try {
const metadata = await ark.get_profile(user);
const contact: Contact = { pubkey: user, profile: metadata };
insertMention(editor, contact);
} catch (e) {
toast.error(String(e));
}
};
useEffect(() => {
async function getContacts() {
const data = await ark.get_contact_list();
setContacts(data);
}
getContacts();
}, []);
return (
<DropdownMenu.Root>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<DropdownMenu.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center",
className,
)}
>
<MentionIcon className="size-4" />
</button>
</Tooltip.Trigger>
</DropdownMenu.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Mention
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
{contacts.map((contact) => (
<DropdownMenu.Item
key={contact}
onClick={() => select(contact)}
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
>
<User.Provider pubkey={contact}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="shrink-0 size-8 rounded-full" />
<User.Name className="text-sm font-medium text-white dark:text-black" />
</User.Root>
</User.Provider>
</DropdownMenu.Item>
))}
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@@ -0,0 +1,40 @@
import { NsfwIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import type { Dispatch, SetStateAction } from "react";
export function NsfwToggle({
nsfw,
setNsfw,
className,
}: {
nsfw: boolean;
setNsfw: Dispatch<SetStateAction<boolean>>;
className?: string;
}) {
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => setNsfw((prev) => !prev)}
className={cn(
"inline-flex items-center justify-center",
className,
nsfw ? "bg-blue-500 text-white" : "",
)}
>
<NsfwIcon className="size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Mark as sensitive content
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,40 @@
import { NsfwIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import type { Dispatch, SetStateAction } from "react";
export function PowToggle({
pow,
setPow,
className,
}: {
pow: boolean;
setPow: Dispatch<SetStateAction<boolean>>;
className?: string;
}) {
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => setPow((prev) => !prev)}
className={cn(
"inline-flex items-center justify-center",
className,
pow ? "bg-blue-500 text-white" : "",
)}
>
<NsfwIcon className="size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Proof of Work
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,341 @@
import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import { MentionNote } from "@lume/ui/src/note/mentions/note";
import {
cn,
insertImage,
insertNostrEvent,
isImageUrl,
sendNativeNotification,
} from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { nip19 } from "nostr-tools";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { type Descendant, Node, Transforms, createEditor } from "slate";
import {
Editable,
ReactEditor,
Slate,
useFocused,
useSelected,
useSlateStatic,
withReact,
} from "slate-react";
import { MediaButton } from "./-components/media";
import { NsfwToggle } from "./-components/nsfw";
import { MentionButton } from "./-components/mention";
type EditorSearch = {
reply_to: string;
quote: boolean;
};
export const Route = createFileRoute("/editor/")({
validateSearch: (search: Record<string, string>): EditorSearch => {
return {
reply_to: search.reply_to,
quote: search.quote === "true" ?? false,
};
},
beforeLoad: async ({ search }) => {
return {
initialValue: search.quote
? [
{
type: "paragraph",
children: [{ text: "" }],
},
{
type: "event",
eventId: `nostr:${nip19.noteEncode(search.reply_to)}`,
children: [{ text: "" }],
},
{
type: "paragraph",
children: [{ text: "" }],
},
]
: [
{
type: "paragraph",
children: [{ text: "" }],
},
],
};
},
component: Screen,
});
function Screen() {
const { reply_to, quote } = Route.useSearch();
const { ark, initialValue } = Route.useRouteContext();
const [t] = useTranslation();
const [editorValue, setEditorValue] = useState(initialValue);
const [loading, setLoading] = useState(false);
const [nsfw, setNsfw] = useState(false);
const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
);
const reset = () => {
// @ts-expect-error, backlog
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
};
const serialize = (nodes: Descendant[]) => {
return nodes
.map((n) => {
// @ts-expect-error, backlog
if (n.type === "image") return n.url;
// @ts-expect-error, backlog
if (n.type === "event") return n.eventId;
// @ts-expect-error, backlog
if (n.children.length) {
// @ts-expect-error, backlog
return n.children
.map((n) => {
if (n.type === "mention") return n.npub;
return Node.string(n).trim();
})
.join(" ");
}
return Node.string(n);
})
.join("\n");
};
const publish = async () => {
try {
// start loading
setLoading(true);
const content = serialize(editor.children);
const eventId = await ark.publish(content, reply_to, quote);
if (eventId) {
await sendNativeNotification(
"Your note has been published successfully.",
"Lume",
);
}
// stop loading
setLoading(false);
// reset form
reset();
} catch (e) {
setLoading(false);
await sendNativeNotification(String(e));
}
};
return (
<div className="w-full h-full">
<Slate editor={editor} initialValue={editorValue}>
<div
data-tauri-drag-region
className="flex h-14 w-full shrink-0 items-center justify-end gap-2 px-2 border-b border-black/10 dark:border-white/10"
>
<NsfwToggle
nsfw={nsfw}
setNsfw={setNsfw}
className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
/>
<MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
<MediaButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
<button
type="button"
onClick={() => publish()}
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
>
{loading ? (
<Spinner className="size-4" />
) : (
<ComposeFilledIcon className="size-4" />
)}
{t("global.post")}
</button>
</div>
<div className="flex h-full w-full flex-1 flex-col">
{reply_to && !quote ? (
<div className="px-4 py-2">
<MentionNote eventId={reply_to} />
</div>
) : null}
<div className="overflow-y-auto p-4">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={true}
autoCapitalize="none"
autoCorrect="none"
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={
reply_to ? "Type your reply..." : t("editor.placeholder")
}
className="focus:outline-none"
/>
</div>
</div>
</Slate>
</div>
);
}
const withNostrEvent = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "event" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (text.startsWith("nevent1") || text.startsWith("note1")) {
insertNostrEvent(editor, text);
} else {
insertData(data);
}
};
return editor;
};
const withMentions = (editor: ReactEditor) => {
const { isInline, isVoid, markableVoid } = editor;
editor.isInline = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isInline(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isVoid(element);
};
editor.markableVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" || markableVoid(element);
};
return editor;
};
const withImages = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "image" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (isImageUrl(text)) {
insertImage(editor, text);
} else {
insertData(data);
}
};
return editor;
};
const Image = ({ attributes, children, element }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const selected = useSelected();
const focused = useFocused();
return (
<div {...attributes}>
{children}
<div contentEditable={false} className="relative my-2">
<img
src={element.url}
alt={element.url}
className={cn(
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
contentEditable={false}
/>
<button
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
>
<TrashIcon className="size-4" />
</button>
</div>
</div>
);
};
const Mention = ({ attributes, element }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<span
{...attributes}
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
>{`@${element.name}`}</span>
);
};
const Event = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<div {...attributes}>
{children}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
<div
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="user-select-none relative my-2"
>
<MentionNote
eventId={element.eventId.replace("nostr:", "")}
openable={false}
/>
</div>
</div>
);
};
const Element = (props) => {
const { attributes, children, element } = props;
switch (element.type) {
case "image":
return <Image {...props} />;
case "mention":
return <Mention {...props} />;
case "event":
return <Event {...props} />;
default:
return (
<p {...attributes} className="text-[15px]">
{children}
</p>
);
}
};

View File

@@ -0,0 +1,71 @@
import { useEvent } from "@lume/ark";
import type { Event } from "@lume/types";
import { Box, Container, Note, Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { WindowVirtualizer } from "virtua";
import { ReplyList } from "./-components/replyList";
export const Route = createFileRoute("/events/$eventId")({
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
return { settings };
},
component: Screen,
});
function Screen() {
const { eventId } = Route.useParams();
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="size-5" />
</div>
);
}
if (isError) {
<div className="flex h-full w-full items-center justify-center">
<p>Not found.</p>
</div>;
}
return (
<Container withDrag>
<Box className="scrollbar-none">
<WindowVirtualizer>
<MainNote data={data} />
{data ? (
<ReplyList eventId={eventId} />
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="size-5" />
</div>
)}
</WindowVirtualizer>
</Box>
</Container>
);
}
function MainNote({ data }: { data: Event }) {
return (
<Note.Provider event={data}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-4 h-11 gap-2 flex items-center justify-end px-3">
<Note.Reply large />
<Note.Repost large />
<Note.Zap large />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -0,0 +1,36 @@
import type { EventWithReplies } from "@lume/types";
import { Note } from "@lume/ui";
import { cn } from "@lume/utils";
import { SubReply } from "./subReply";
export function Reply({ event }: { event: EventWithReplies }) {
return (
<Note.Provider event={event}>
<Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-3 flex items-center gap-4 px-3 h-14">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
<div
className={cn(
event.replies?.length > 0
? "py-2 pl-3 flex flex-col gap-3 divide-y divide-neutral-100 bg-neutral-50 dark:bg-white/5 border-l-2 border-blue-500 dark:divide-neutral-900"
: "",
)}
>
{event.replies?.length > 0
? event.replies?.map((childEvent) => (
<SubReply key={childEvent.id} event={childEvent} />
))
: null}
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -0,0 +1,51 @@
import type { EventWithReplies } from "@lume/types";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Reply } from "./reply";
export function ReplyList({
eventId,
className,
}: {
eventId: string;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const [t] = useTranslation();
const [data, setData] = useState<null | EventWithReplies[]>(null);
useEffect(() => {
async function getReplies() {
const events = await ark.get_event_thread(eventId);
setData(events);
}
getReplies();
}, [eventId]);
return (
<div className={cn("flex flex-col", className)}>
<div className="h-11 flex px-3 items-center text-sm font-semibold text-neutral-700 dark:text-neutral-300 border-t border-neutral-100 dark:border-neutral-900">
Replies ({data?.length ?? 0})
</div>
{!data ? (
<div className="flex h-16 items-center justify-center p-3">
<Spinner className="size-5" />
</div>
) : data.length === 0 ? (
<div className="flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
{t("note.reply.empty")}
</p>
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} />)
)}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import type { Event } from "@lume/types";
import { Note } from "@lume/ui";
export function SubReply({ event }: { event: Event; rootEventId?: string }) {
return (
<Note.Provider event={event}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-3 flex items-center gap-4 px-3">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -0,0 +1,155 @@
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/foryou")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
beforeLoad: async ({ search, context }) => {
const ark = context.ark;
const interests = await ark.get_interest();
const settings = await ark.get_settings();
if (!interests) {
throw redirect({
to: "/interests",
search: {
...search,
redirect: "/foryou",
},
});
}
return {
interests,
settings,
};
},
component: Screen,
});
export function Screen() {
const { name, account } = Route.useSearch();
const { ark, interests } = Route.useRouteContext();
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: [name, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events_from_interests(
interests.hashtags,
20,
pageParam,
);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="w-full h-11 flex items-center justify-center">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span>
</div>
</div>
) : null}
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</div>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/global")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
return { settings };
},
component: Screen,
});
export function Screen() {
const { account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ["global", account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events(20, pageParam, undefined, true);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />;
}
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
};
return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="w-full h-11 flex items-center justify-center">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span>
</div>
</div>
) : null}
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</div>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/group")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
beforeLoad: async ({ search, context }) => {
const ark = context.ark;
const groups = (await ark.get_nstore(
`lume_group_${search.label}`,
)) as string[];
const settings = await ark.get_settings();
if (!groups) {
throw redirect({
to: "/create-group",
search: {
...search,
redirect: "/group",
},
});
}
return {
groups,
settings,
};
},
component: Screen,
});
export function Screen() {
const { name, account } = Route.useSearch();
const { ark, groups } = Route.useRouteContext();
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: [name, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events(20, pageParam, groups);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
select: (data) =>
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="w-full h-11 flex items-center justify-center">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span>
</div>
</div>
) : null}
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</div>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { PlusIcon } from "@lume/icons";
import { Spinner, User } from "@lume/ui";
import { checkForAppUpdates } from "@lume/utils";
import { Link } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/")({
beforeLoad: async ({ context }) => {
// check for app updates
await checkForAppUpdates(true);
const ark = context.ark;
const accounts = await ark.get_all_accounts();
if (!accounts.length) {
throw redirect({
to: "/landing",
replace: true,
});
}
// Run notification service
await invoke("run_notification", { accounts });
return { accounts };
},
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const { ark, accounts } = Route.useRouteContext();
const [loading, setLoading] = useState(false);
const select = async (npub: string) => {
try {
setLoading(true);
const loadAccount = await ark.load_selected_account(npub);
if (loadAccount) {
return navigate({
to: "/$account/home",
params: { account: npub },
replace: true,
});
}
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
const currentDate = new Date().toLocaleString("default", {
weekday: "long",
month: "long",
day: "numeric",
});
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="relative z-20 flex flex-col items-center gap-16">
<div className="text-center text-white">
<h2 className="mb-1 text-2xl">{currentDate}</h2>
<h2 className="text-2xl font-semibold">Welcome back!</h2>
</div>
<div className="flex items-center justify-center gap-6">
{loading ? (
<div className="inline-flex size-6 items-center justify-center">
<Spinner className="size-6 text-white" />
</div>
) : (
<>
{accounts.map((account) => (
<button
type="button"
key={account}
onClick={() => select(account)}
>
<User.Provider pubkey={account}>
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10">
<User.Avatar className="size-20 rounded-full object-cover" />
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
</User.Root>
</User.Provider>
</button>
))}
<Link to="/landing">
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
<PlusIcon className="size-5" />
</div>
<p className="text-lg font-medium leading-tight">Add</p>
</div>
</Link>
</>
)}
</div>
</div>
<div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" />
<div className="absolute inset-0 h-full w-full">
<img
src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x"
alt="Lock Screen Background"
className="h-full w-full object-cover"
/>
<a
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
target="_blank"
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white dark:bg-black/20"
rel="noreferrer"
>
Design by NoGood
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import type { ColumnRouteSearch } from "@lume/types";
import { TOPICS, cn } from "@lume/utils";
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/interests")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
function Screen() {
const { t } = useTranslation();
const { label, name, redirect } = Route.useSearch();
const { ark } = Route.useRouteContext();
const [hashtags, setHashtags] = useState<string[]>([]);
const [isDone, setIsDone] = useState(false);
const router = useRouter();
const toggleHashtag = (item: string) => {
const arr = hashtags.includes(item)
? hashtags.filter((i) => i !== item)
: [...hashtags, item];
setHashtags(arr);
};
const toggleAll = (item: string[]) => {
const sets = new Set([...hashtags, ...item]);
setHashtags([...sets]);
};
const submit = async () => {
try {
if (isDone) {
return router.history.push(redirect);
}
const eventId = await ark.set_interest(undefined, undefined, hashtags);
if (eventId) {
setIsDone(true);
toast.success("Interest has been updated successfully.");
}
} catch (e) {
toast.error(String(e));
}
};
return (
<div className="h-full flex flex-col px-2">
<div className="shrink-0 flex h-16 items-center justify-between">
<div className="flex flex-1 flex-col">
<h3 className="font-semibold">Interests</h3>
<p className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
Pick things you'd like to see.
</p>
</div>
<button
type="button"
onClick={submit}
className="inline-flex h-8 w-20 items-center justify-center rounded-full bg-blue-500 px-2 text-sm font-medium text-white hover:bg-blue-600 disabled:opacity-50"
>
{isDone ? t("global.back") : t("global.update")}
</button>
</div>
<div className="flex-1 flex flex-col gap-3 pb-2 scrollbar-none overflow-y-auto">
{TOPICS.map((topic) => (
<div
key={topic.title}
className="flex flex-col gap-4 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
>
<div className="px-3 flex w-full items-center justify-between h-14 shrink-0 border-b border-neutral-100 dark:border-neutral-900">
<div className="inline-flex items-center gap-2.5">
<img
src={topic.icon}
alt={topic.title}
className="size-8 rounded-lg object-cover"
/>
<h3 className="text-lg font-semibold">{topic.title}</h3>
</div>
<button
type="button"
onClick={() => toggleAll(topic.content)}
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
{t("interests.followAll")}
</button>
</div>
<div className="px-3 pb-3 flex flex-wrap items-center gap-3">
{topic.content.map((hashtag) => (
<button
key={hashtag}
type="button"
onClick={() => toggleHashtag(hashtag)}
className={cn(
"inline-flex items-center rounded-full border border-transparent bg-neutral-100 px-2 py-1 text-sm font-medium dark:bg-neutral-900",
hashtags.includes(hashtag)
? "border-blue-500 text-blue-500"
: "",
)}
>
{hashtag}
</button>
))}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { KeyIcon, RemoteIcon } from "@lume/icons";
import { Link, createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/landing/")({
component: Screen,
});
function Screen() {
const { t } = useTranslation();
return (
<div className="relative flex h-screen w-screen bg-black">
<div
data-tauri-drag-region
className="absolute left-0 top-0 z-50 h-16 w-full"
/>
<div className="z-20 flex h-full w-full flex-col items-center justify-between">
<div />
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-10">
<div className="flex flex-col items-center text-center">
<img
src="/heading-en.png"
srcSet="/heading-en@2x.png 2x"
alt="lume"
className="xl:w-2/3"
/>
<p className="mt-4 whitespace-pre-line text-lg font-medium leading-snug text-white/70">
{t("welcome.title")}
</p>
</div>
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
<Link
to="/auth/new/profile"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white font-medium text-blue-500 backdrop-blur-lg hover:bg-white/90"
>
{t("welcome.signup")}
</Link>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-white/20" />
<div className="text-white/70">{t("login.or")}</div>
<div className="h-px flex-1 bg-white/20" />
</div>
<div className="flex flex-col gap-2">
<Link
to="/auth/remote"
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
>
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
Nostr Connect
<div className="size-5" />
</Link>
<Link
to="/auth/privkey"
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
>
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
Private Key
<div className="size-5" />
</Link>
</div>
</div>
</div>
<div className="flex h-11 items-center justify-center" />
</div>
<div className="absolute z-10 h-full w-full bg-black/5 backdrop-blur-sm" />
<div className="absolute inset-0 h-full w-full">
<img
src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x"
alt="Lock Screen Background"
className="h-full w-full object-cover"
/>
<a
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
target="_blank"
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white backdrop-blur-md dark:bg-black/20"
rel="noreferrer"
>
Design by NoGood
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/newsfeed")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
return { settings };
},
component: Screen,
});
export function Screen() {
const { label, account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: [label, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events(20, pageParam);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />;
}
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
};
return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="w-full h-11 flex items-center justify-center">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span>
</div>
</div>
) : null}
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</div>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { ZapIcon } from "@lume/icons";
import { Container } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createLazyFileRoute("/nwc")({
component: Screen,
});
function Screen() {
const { ark } = Route.useRouteContext();
const [uri, setUri] = useState("");
const [isDone, setIsDone] = useState(false);
const save = async () => {
const nwc = await ark.set_nwc(uri);
setIsDone(nwc);
};
return (
<Container withDrag withNavigate={false}>
<div className="h-full w-full flex-1 px-5">
{!isDone ? (
<>
<div className="flex flex-col gap-2">
<div className="inline-flex size-14 items-center justify-center rounded-xl bg-black text-white shadow-md">
<ZapIcon className="size-5" />
</div>
<div>
<h3 className="text-2xl font-light">
Connect <span className="font-semibold">bitcoin wallet</span>{" "}
to start zapping to your favorite content and creator.
</h3>
</div>
</div>
<div className="mt-10 flex flex-col gap-2">
<div className="flex flex-col gap-1.5">
<label>Paste a Nostr Wallet Connect connection string</label>
<textarea
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://"
className="h-24 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<button
type="button"
onClick={save}
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
>
Save & Connect
</button>
</div>
</>
) : (
<div>Done</div>
)}
</div>
</Container>
);
}

View File

@@ -0,0 +1,445 @@
import { ArrowRightIcon, CancelIcon } from "@lume/icons";
import type { ColumnRouteSearch, LumeColumn } from "@lume/types";
import { Spinner, User } from "@lume/ui";
import { cn } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { getCurrent } from "@tauri-apps/api/window";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/onboarding")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
function Screen() {
const { label } = Route.useSearch();
const {
register,
handleSubmit,
reset,
formState: { isValid, isSubmitting },
} = useForm();
const [userType, setUserType] = useState<"new" | "veteran">(null);
const install = async (column: LumeColumn) => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
};
const close = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "remove", label });
};
const friendToFriend = async (data: { npub: string }) => {
if (!data.npub.startsWith("npub1"))
return toast.warning(
"NPUB is invalid. NPUB must be starts with npub1...",
);
try {
const connect: boolean = await invoke("friend_to_friend", {
npub: data.npub,
});
if (connect) {
const column = {
label: "newsfeed",
name: "Newsfeed",
content: "/newsfeed",
};
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
// reset form
reset();
}
} catch (e) {
toast.error(String(e));
}
};
return (
<div className="h-full flex flex-col py-6 gap-6 overflow-y-auto scrollbar-none">
<div className="text-center flex flex-col items-center justify-center">
<h1 className="text-2xl font-serif font-medium">Welcome to Lume</h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are a few suggestions to help you get started.
</p>
</div>
<div className="px-3">
<div className="mb-6 w-full h-44">
<img
src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x"
alt="background"
className="h-full w-full object-cover rounded-xl outline outline-1 -outline-offset-1 outline-black/15"
/>
</div>
<div className="flex flex-col gap-6">
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
👋 Yo! I'm Mide, and I'll be your friendly guide to Nostr and
beyond. Looking forward to our adventure together!
</div>
</div>
</div>
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
<CurrentUser />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold text-end">You</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
How can I get started?
</div>
<button
type="button"
onClick={() => setUserType("new")}
className={cn(
"mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg",
userType === "new"
? "bg-blue-500 text-white hover:bg-blue-600"
: "",
)}
>
I'm completely new to Nostr.
<ArrowRightIcon className="size-4" />
</button>
<button
type="button"
onClick={() => setUserType("veteran")}
className={cn(
"mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg",
userType === "veteran"
? "bg-blue-500 text-white hover:bg-blue-600"
: "",
)}
>
I've already been using another Nostr client.
<ArrowRightIcon className="size-4" />
</button>
</div>
</div>
{userType === "veteran" ? (
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
So, I'm excited to give you a quick intro to Lume and all the
awesome features it has to offer. Let's dive in!
</div>
</div>
</div>
) : null}
{userType === "veteran" ? (
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
<CurrentUser />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold text-end">You</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Thanks! But I already know about Lume.
</div>
<button
type="button"
onClick={() =>
install({
label: "newsfeed",
name: "Newsfeed",
content: "/newsfeed",
})
}
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
>
Skip! Show my newsfeed
<ArrowRightIcon className="size-4" />
</button>
</div>
</div>
) : null}
{userType === "veteran" ? (
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
First off, Lume is a social media client for Nostr. It's a
place where you can follow friends, dive into chats, and post
what's on your mind.
</div>
</div>
</div>
) : null}
{userType === "veteran" ? (
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
That's not all! What makes Lume unique is the column system.
You can enhance your experience by adding new columns from the
Lume Store.
</div>
<div className="mt-1 p-2 bg-black/5 dark:bg-white/5 rounded-lg">
If you're confused about the term "Column," you can imagine it
as mini-apps, with each column providing its own experience.
</div>
<div className="mt-1 p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Here is a quick guide for how to add a new column:
</div>
<div className="mt-1 rounded-lg">
<video
className="h-auto w-full rounded-lg object-cover aspect-video outline outline-1 -outline-offset-1 outline-black/15"
controls
muted
>
<source
src="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
</div>
) : null}
{userType === "veteran" ? (
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
<CurrentUser />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold text-end">You</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Can you introduce me to the UI? I am still confused.
</div>
</div>
</div>
) : null}
{userType === "veteran" ? (
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Of course, here is a quick introduction video for Lume.
</div>
<div className="mt-1 rounded-lg">
<video
className="h-auto w-full rounded-lg object-cover aspect-video outline outline-1 -outline-offset-1 outline-black/15"
controls
muted
>
<source
src="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
</div>
) : null}
{userType === "new" ? (
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Diving into new social media platforms like Nostr can be a bit
overwhelming, but don't worry! Here are some handy tips to
help you navigate and discover what interests you.
</div>
<button
type="button"
onClick={() =>
install({
label: "foryou",
name: "For you",
content: "/foryou",
})
}
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
>
Add some topics that you're interested in.
<ArrowRightIcon className="size-4" />
</button>
<button
type="button"
onClick={() =>
install({
label: "trending_users",
name: "Trending",
content: "/trending/users",
})
}
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
>
Follow some users.
<ArrowRightIcon className="size-4" />
</button>
</div>
</div>
) : null}
{userType === "new" ? (
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
<CurrentUser />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold text-end">You</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
My girlfriend introduced Nostr to me, and I have her NPUB. Can
I get the same experiences as her?
</div>
</div>
</div>
) : null}
{userType === "new" ? (
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Absolutely! Since your girlfriend shared her NPUB with you,
you can dive into Nostr and explore it just like she does.
It's a great way to share experiences and discover what Nostr
has to offer together!
</div>
<form
onSubmit={handleSubmit(friendToFriend)}
className="mt-1 flex flex-col items-end bg-white dark:bg-white/10 rounded-lg shadow-primary"
>
<input
{...register("npub", { required: true })}
name="npub"
placeholder="Enter npub here..."
className="w-full h-14 px-3 rounded-t-lg bg-transparent border-b border-x-0 border-t-0 border-neutral-100 dark:border-white/5 focus:border-neutral-200 dark:focus:border-white/20 focus:outline-none focus:ring-0 placeholder:text-neutral-600 dark:placeholder:text-neutral-400"
/>
<div className="h-10 flex items-center px-1">
<button
type="submit"
disabled={!isValid || isSubmitting}
className="px-2 h-8 w-20 inline-flex items-center justify-center bg-blue-500 text-white rounded-md text-sm font-medium hover:bg-blue-600"
>
{isSubmitting ? <Spinner className="size-4" /> : "Submit"}
</button>
</div>
</form>
</div>
</div>
) : null}
{userType ? (
<>
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
<CurrentUser />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold text-end">You</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Thank you. I can use Lume and explore Nostr by myself from
now on.
</div>
</div>
</div>
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
I really hope you enjoy your time on Nostr! If you're keen
to dive deeper, here are some helpful resources to get you
started:
</div>
<a
href="https://nostr.org"
target="_blank"
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
rel="noreferrer"
>
[Website] nostr.org
<ArrowRightIcon className="size-4" />
</a>
<a
href="https://www.youtube.com/watch?v=5W-jtbbh3eA"
target="_blank"
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
rel="noreferrer"
>
[Video] What is Nostr?
<ArrowRightIcon className="size-4" />
</a>
<a
href="https://github.com/nostr-protocol/nostr"
target="_blank"
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
rel="noreferrer"
>
[Develop] Github
<ArrowRightIcon className="size-4" />
</a>
<a
href="https://www.nostrapps.com/"
target="_blank"
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
rel="noreferrer"
>
[Ecosystem] nostrapps.com
<ArrowRightIcon className="size-4" />
</a>
</div>
</div>
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
If you want to close this onboarding board, you can click
the button below.
</div>
<button
type="button"
onClick={() => close()}
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
>
Close
<CancelIcon className="size-4" />
</button>
</div>
</div>
</>
) : null}
</div>
</div>
</div>
);
}
function Mide() {
return (
<img
src="/ai.jpg"
alt="Ai-chan"
className="shrink-0 size-10 rounded-full outline outline-1 -outline-offset-1 outline-black/15"
/>
);
}
function CurrentUser() {
const { account } = Route.useSearch();
return (
<User.Provider pubkey={account}>
<User.Root className="shrink-0">
<User.Avatar className="size-10 rounded-full outline outline-1 -outline-offset-1 outline-black/15" />
</User.Root>
</User.Provider>
);
}

View File

@@ -0,0 +1,48 @@
import { PlusIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types";
import { createLazyRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/window";
export const Route = createLazyRoute("/open")({
component: Screen,
});
function Screen() {
const install = async (column: LumeColumn) => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="group absolute left-0 top-0 z-10 h-full w-12">
<button
type="button"
onClick={() =>
install({
label: "store",
name: "Store",
content: "/store/official",
})
}
className="flex h-full w-full items-center justify-center rounded-xl bg-transparent transition-colors duration-100 ease-in-out group-hover:bg-black/5 dark:group-hover:bg-white/5"
>
<PlusIcon className="size-6 scale-0 transform transition-transform duration-150 ease-in-out will-change-transform group-hover:scale-100" />
</button>
</div>
<button
type="button"
onClick={() =>
install({
label: "store",
name: "Store",
content: "/store/official",
})
}
className="inline-flex size-14 items-center justify-center rounded-full bg-black/10 backdrop-blur-lg hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<PlusIcon className="size-8" />
</button>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import { SearchIcon } from "@lume/icons";
import { type Event, Kind } from "@lume/types";
import { Note, Spinner, User } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useDebounce } from "use-debounce";
export const Route = createFileRoute("/search")({
component: Screen,
});
function Screen() {
const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<Event[]>([]);
const [search, setSearch] = useState("");
const [searchValue] = useDebounce(search, 500);
const searchEvents = async () => {
try {
setLoading(true);
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
const res = await fetch(query);
const content = await res.json();
const events = content.data as Event[];
const sorted = events.sort((a, b) => b.created_at - a.created_at);
setLoading(false);
setEvents(sorted);
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
useEffect(() => {
if (searchValue.length >= 3 && searchValue.length < 500) {
searchEvents();
}
}, [searchValue]);
return (
<div data-tauri-drag-region className="flex flex-col w-full h-full">
<div className="relative h-24 shrink-0 flex flex-col border-b border-black/5 dark:border-white/5">
<div data-tauri-drag-region className="w-full h-4 shrink-0" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") searchEvents();
}}
placeholder="Search anything..."
className="w-full h-20 pt-10 px-3 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
/>
</div>
<div className="flex-1 p-3 overflow-y-auto scrollbar-none">
{loading ? (
<div className="w-full h-full flex items-center justify-center">
<Spinner />
</div>
) : events.length ? (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Users
</div>
<div className="flex-1 flex flex-col gap-1">
{events
.filter((ev) => ev.kind === Kind.Metadata)
.map((event, index) => (
<SearchUser key={event.pubkey + index} event={event} />
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Notes
</div>
<div className="flex-1 flex flex-col gap-3">
{events
.filter((ev) => ev.kind === Kind.Text)
.map((event) => (
<SearchNote key={event.id} event={event} />
))}
</div>
</div>
</div>
) : null}
{!loading && !events.length ? (
<div className="h-full flex items-center justify-center flex-col gap-3">
<div className="size-16 bg-black/10 dark:bg-white/10 rounded-full inline-flex items-center justify-center">
<SearchIcon className="size-6" />
</div>
Try searching for people, notes, or keywords
</div>
) : null}
</div>
</div>
);
}
function SearchUser({ event }: { event: Event }) {
const { ark } = Route.useRouteContext();
return (
<button
key={event.id}
type="button"
onClick={() => ark.open_profile(event.pubkey)}
className="col-span-1 p-2 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg"
>
<User.Provider pubkey={event.pubkey} embedProfile={event.content}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="size-9 rounded-full shrink-0" />
<div className="inline-flex items-center gap-1.5">
<User.Name className="font-semibold" />
<User.NIP05 />
</div>
</User.Root>
</User.Provider>
</button>
);
}
function SearchNote({ event }: { event: Event }) {
return (
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<Note.Provider event={event}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" quote={false} mention={false} />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import {
RelayIcon,
SecureIcon,
SettingsIcon,
UserIcon,
ZapIcon,
} from "@lume/icons";
import { cn } from "@lume/utils";
import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/settings")({
component: Screen,
});
function Screen() {
const { t } = useTranslation();
return (
<div className="flex h-full w-full flex-col">
<div
data-tauri-drag-region
className="flex h-20 w-full shrink-0 items-center justify-center border-b border-black/10 dark:border-white/10"
>
<div className="flex items-center gap-1">
<Link to="/settings/general">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<SettingsIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.general.title")}
</p>
</div>
);
}}
</Link>
<Link to="/settings/user">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<UserIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.user.title")}
</p>
</div>
);
}}
</Link>
<Link to="/settings/relay">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<RelayIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">Relay</p>
</div>
);
}}
</Link>
<Link to="/settings/zap">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<ZapIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.zap.title")}
</p>
</div>
);
}}
</Link>
<Link to="/settings/backup">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<SecureIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.backup.title")}
</p>
</div>
);
}}
</Link>
</div>
</div>
<div className="w-full flex-1 overflow-y-auto scrollbar-none px-5 py-4">
<Outlet />
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import type { Account } from "@lume/types";
import { User } from "@lume/ui";
import { displayNsec } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/backup")({
component: Screen,
loader: async ({ context }) => {
const ark = context.ark;
const npubs = await ark.get_all_accounts();
const accounts: Account[] = [];
for (const account of npubs) {
const nsec: string = await invoke("get_stored_nsec", {
npub: account.npub,
});
accounts.push({ ...account, nsec });
}
return accounts;
},
});
function Screen() {
const accounts = Route.useLoaderData();
return (
<div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
{accounts.map((account) => (
<NostrAccount key={account.npub} account={account} />
))}
</div>
</div>
);
}
function NostrAccount({ account }: { account: Account }) {
const [key, setKey] = useState(account.nsec);
const [copied, setCopied] = useState(false);
const [passphase, setPassphase] = useState("");
const encrypt = async () => {
const encrypted: string = await invoke("get_encrypted_key", {
npub: account.npub,
password: passphase,
});
setKey(encrypted);
};
const copyKey = async () => {
try {
await writeText(key);
setCopied(true);
} catch (e) {
toast.error(e);
}
};
return (
<div className="flex flex-1 flex-col gap-2 py-3">
<User.Provider pubkey={account.npub}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="size-8 rounded-full object-cover" />
<div className="flex flex-col">
<User.Name className="text-sm leading-tight" />
<User.NIP05 />
</div>
</User.Root>
</User.Provider>
<div className="flex flex-col gap-2">
<div className="flex w-full flex-col gap-1">
<label
htmlFor="nsec"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Private Key
</label>
<div className="flex items-center gap-2">
<input
readOnly
name="nsec"
type="text"
value={displayNsec(key, 36)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => copyKey()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="passphase"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Set a passphase to secure your key
</label>
<div className="flex items-center gap-2">
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => encrypt()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Update
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import type { Settings } from "@lume/types";
import * as Switch from "@radix-ui/react-switch";
import { createFileRoute } from "@tanstack/react-router";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
export const Route = createFileRoute("/settings/general")({
beforeLoad: async ({ context }) => {
const permissionGranted = await isPermissionGranted(); // get notification permission
const ark = context.ark;
const settings = await ark.get_settings();
return {
settings: { ...settings, notification: permissionGranted },
};
},
component: Screen,
});
function Screen() {
const { ark, settings } = Route.useRouteContext();
const [newSettings, setNewSettings] = useState<Settings>(settings);
const toggleNofitication = async () => {
await requestPermission();
setNewSettings((prev) => ({
...prev,
notification: !newSettings.notification,
}));
};
const toggleAutoUpdate = () => {
setNewSettings((prev) => ({
...prev,
autoUpdate: !newSettings.autoUpdate,
}));
};
const toggleEnhancedPrivacy = () => {
setNewSettings((prev) => ({
...prev,
enhancedPrivacy: !newSettings.enhancedPrivacy,
}));
};
const toggleZap = () => {
setNewSettings((prev) => ({
...prev,
zap: !newSettings.zap,
}));
};
const toggleNsfw = () => {
setNewSettings((prev) => ({
...prev,
nsfw: !newSettings.nsfw,
}));
};
const updateSettings = useDebouncedCallback(() => {
ark.set_settings(newSettings);
}, 200);
useEffect(() => {
updateSettings();
}, [newSettings]);
return (
<div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
General
</h2>
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Notification</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
By turning on push notifications, you'll start getting
notifications from Lume directly.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.notification}
onClick={() => toggleNofitication()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-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>
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Enhanced Privacy</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Lume presents external resources like images, videos, or link
previews in plain text.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.enhancedPrivacy}
onClick={() => toggleEnhancedPrivacy()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-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>
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Auto Update</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically download and install new version.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.autoUpdate}
onClick={() => toggleAutoUpdate()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-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>
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-semibold">Filter sensitive content</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
By default, Lume will display all content which have Content
Warning tag, it's may include NSFW content.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.nsfw}
onClick={() => toggleNsfw()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-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>
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
Interface
</h2>
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
<div className="flex flex-col gap-4">
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-semibold">Zap</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Show the Zap button in each note and user's profile screen,
use for send bitcoin tip to other users.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.zap}
onClick={() => toggleZap()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-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>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import { CancelIcon, PlusIcon } from "@lume/icons";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/relay")({
loader: async ({ context }) => {
const ark = context.ark;
const relays = await ark.get_relays();
return relays;
},
component: Screen,
});
function Screen() {
const relayList = Route.useLoaderData();
const [relays, setRelays] = useState(relayList.connected);
const { ark } = Route.useRouteContext();
const { register, reset, handleSubmit } = useForm();
const onSubmit = async (data: { url: string }) => {
try {
const add = await ark.add_relay(data.url);
if (add) {
setRelays((prev) => [...prev, data.url]);
reset();
}
} catch (e) {
toast.error(String(e));
}
};
return (
<div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
Connected Relays
</h2>
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
{relays.map((relay) => (
<div
key={relay}
className="flex justify-between items-center h-11"
>
<div className="inline-flex items-center gap-2 text-sm font-medium">
<span className="relative flex size-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75"></span>
<span className="relative inline-flex rounded-full size-2 bg-teal-500"></span>
</span>
{relay}
</div>
<div>
<button
type="button"
className="inline-flex items-center justify-center size-7 rounded-md hover:bg-black/10 dark:hover:bg-white/10"
>
<CancelIcon className="size-4" />
</button>
</div>
</div>
))}
<div className="flex items-center h-14">
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full flex items-center gap-2 mb-0"
>
<input
{...register("url", {
required: true,
minLength: 1,
})}
name="url"
placeholder="wss://..."
spellCheck={false}
className="h-9 flex-1 rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:border-neutral-700 dark:placeholder:text-neutral-400"
/>
<button
type="submit"
className="shrink-0 inline-flex h-9 w-16 px-2 items-center justify-center rounded-lg bg-black/20 dark:bg-white/20 font-medium text-sm text-white hover:bg-blue-500 disabled:opacity-50"
>
<PlusIcon className="size-7" />
</button>
</form>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
User Relays (NIP-65)
</h2>
<div className="flex flex-col py-2 bg-black/5 dark:bg-white/5 rounded-xl px-3">
<p className="text-sm text-yellow-500">
Lume will automatically connect to the user's relay list, but the
manager function (like adding, removing, changing relay purpose)
is not yet available.
</p>
</div>
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
{relayList.read?.map((relay) => (
<div
key={relay}
className="flex justify-between items-center h-11"
>
<div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">READ</div>
</div>
))}
{relayList.write?.map((relay) => (
<div
key={relay}
className="flex justify-between items-center h-11"
>
<div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">WRITE</div>
</div>
))}
{relayList.both?.map((relay) => (
<div
key={relay}
className="flex justify-between items-center h-11"
>
<div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">READ + WRITE</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons";
import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui";
import { Link } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/user")({
beforeLoad: async ({ context }) => {
const ark = context.ark;
const profile = await ark.get_current_user_profile();
return { profile };
},
component: Screen,
});
function Screen() {
const { ark, profile } = Route.useRouteContext();
const { register, handleSubmit } = useForm();
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState<string>("");
const onSubmit = async (data: Metadata) => {
try {
setLoading(true);
const profile = { ...data, picture };
await ark.create_profile(profile);
setLoading(false);
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
return (
<div className="flex w-full h-full">
<div className="flex-1 h-full flex items-center flex-col justify-center gap-3">
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{profile.picture ? (
<img
src={picture || profile.picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
/>
) : null}
<AvatarUploader
setPicture={setPicture}
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<PlusIcon className="size-8" />
</AvatarUploader>
</div>
<div className="text-center flex flex-col items-center">
<div className="text-lg font-semibold">{profile.display_name}</div>
<div className="text-neutral-800 dark:text-neutral-200">
{profile.nip05}
</div>
<div className="mt-4">
<Link
to="/settings/backup"
className="px-5 h-9 border border-blue-300 text-sm font-medium hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 rounded-full bg-blue-100 text-blue-500 inline-flex items-center justify-center"
>
Backup Account
</Link>
</div>
</div>
</div>
<div className="flex-1 h-full">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3 mb-0"
>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="display_name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Display Name
</label>
<input
name="display_name"
{...register("display_name", { required: true, minLength: 1 })}
spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Name
</label>
<input
name="name"
{...register("name")}
spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Website
</label>
<input
name="website"
type="url"
{...register("website")}
spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="banner"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Cover
</label>
<input
name="banner"
type="url"
{...register("banner")}
spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="nip05"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
NIP-05
</label>
<input
name="nip05"
type="email"
{...register("nip05")}
spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="lnaddress"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Lightning Address
</label>
<input
name="lnaddress"
type="email"
{...register("lud16")}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex items-center justify-end">
<button
type="submit"
className="inline-flex h-9 w-32 px-2 items-center justify-center rounded-lg bg-blue-500 font-medium text-sm text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner className="size-4" /> : "Update Profile"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/settings/zap")({
component: Screen,
});
function Screen() {
return (
<div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
<div className="flex flex-col gap-6 py-3">
<Connection />
<DefaultAmount />
</div>
</div>
</div>
);
}
function Connection() {
const [uri, setUri] = useState("");
const connect = async () => {
try {
await invoke("set_nwc", { uri });
} catch (e) {
toast.error(String(e));
}
};
return (
<div className="flex items-start gap-6">
<div className="w-36 shrink-0 text-end font-medium text-sm">
Connection
</div>
<div className="flex-1">
<div className="flex w-full flex-col gap-1">
<label
htmlFor="nwc"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Nostr Wallet Connect
</label>
<div className="flex items-center gap-2">
<input
name="nwc"
type="text"
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://"
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => connect()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Connect
</button>
</div>
</div>
</div>
</div>
);
}
function DefaultAmount() {
return (
<div className="flex items-start gap-6">
<div className="w-36 shrink-0 text-end font-medium text-sm">
Default amount
</div>
<div className="flex-1">
<div className="flex w-full flex-col gap-1">
<label
htmlFor="amount"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Set default amount for quick zapping
</label>
<div className="flex items-center gap-2">
<input
name="amount"
type="number"
value={21}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Update
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/store/community")({
component: Screen,
});
function Screen() {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-3">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<div className="text-center">
<h1 className="font-semibold text-lg">Coming Soon</h1>
<p className="text-sm text-neutral-700 dark:text-neutral-300 leading-tight">
Enhance your experience <br /> by adding column shared by community.
</p>
</div>
</div>
);
}

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