101 Commits

Author SHA1 Message Date
75c3783522 feat: rewrite the nip-4e implementation (#1)
Some checks are pending
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Make NIP-4e a core feature, not an optional feature.

Note:
- The UI is broken and needs to be updated in a separate PR.

Reviewed-on: #1
2026-01-13 16:00:08 +08:00
reya
bb455871e5 remove i18n crate (#213) 2025-12-31 09:23:59 +07:00
0507fa7ac5 chore: update deps 2025-12-26 08:25:22 +07:00
af115321b4 Merge branch 'master' of github.com:lumehq/coop 2025-12-26 08:23:57 +07:00
reya
34e026751b feat: add support for multi-themes (#210)
* chore: update deps

* wip

* add themes

* add matrix theme

* add flexoki and spaceduck themes

* .

* simple theme change function

* .

* respect shadow and radius settings

* add rose pine themes

* toggle theme
2025-12-26 08:20:18 +07:00
e9e662dccc chore: update deps 2025-12-23 10:10:50 +07:00
5b7780ec9b chore: update deps 2025-12-17 08:10:02 +07:00
782efd7498 chore: update deps 2025-12-13 10:06:09 +07:00
8192023479 chore: release version 0.3.0 2025-12-08 11:10:54 +07:00
4637478a0b chore: minor fixes 2025-12-05 10:26:24 +07:00
6b5adb0a56 chore: update deps 2025-12-05 08:10:36 +07:00
9fd55cf3ff chore: update deps 2025-11-30 09:47:40 +07:00
reya
14c36e4731 feat: refine the search bar (#207)
* update deps

* refactor the search cancellation

* .

* .
2025-11-22 07:25:08 +07:00
reya
a6e00b47d8 feat: refine user profile popup (#206)
* update user popup

* .

* .
2025-11-20 08:23:02 +07:00
0784a20be5 chore: update deps 2025-11-18 07:36:31 +07:00
reya
6023063cf4 feat: revamp the onboarding process (#205)
* redesign

* restructure

* .

* .

* .

* .

* .
2025-11-17 15:10:14 +07:00
67c92cb319 chore: update deps 2025-11-15 14:51:16 +07:00
reya
122299f548 chore: improve nip4e implementation (#204)
* patch

* update ui

* add load response

* fix

* .

* wip: rewrite gossip

* new gossip implementation

* clean up

* .

* debug

* .

* .

* update

* .

* fix

* fix
2025-11-15 08:30:45 +07:00
d87bcfbd65 chore: follow up on #203 2025-11-11 11:02:37 +07:00
de5134676d chore: update deps 2025-11-11 09:25:46 +07:00
reya
512834b640 chore: rewrite the backend (not tested) (#203)
* wip: refactor

* refactor

* clean up

* .

* rename

* add relay auth

* .

* .

* optimize

* .

* clean up

* add encryption crate

* .

* .

* .

* .

* .

* add encryption crate

* .

* refactor nip4e

* .

* fix endless loop

* fix metadata fetching
2025-11-11 09:09:33 +07:00
a1a0a7ecd4 chore: update deps 2025-11-03 19:09:59 +07:00
reya
a4067d2c00 chore: fix crash when failing to parse message (#202)
* clean up

* .

* fix rich text component

* clean up
2025-11-03 19:04:16 +07:00
4ebe590f8a chore: update deps 2025-11-02 20:27:47 +07:00
reya
9da624dd0c feat: nostr based auto updater (#200)
* .

* refactor

* fix

* .

* clean up

* clean up
2025-11-02 08:22:55 +07:00
reya
7091fa1cab chore: restructure and refine the ui (#199)
* update deps

* clean up

* add account crate

* add person crate

* add chat and chat ui crates

* .

* clean up the ui crate

* .

* .
2025-11-01 09:16:02 +07:00
a1bd4954eb chore: clean up 2025-10-29 08:30:04 +07:00
fde1499796 chore: update deps 2025-10-29 07:52:53 +07:00
reya
649cdff49c re add verify relay connection (#197) 2025-10-29 07:42:50 +07:00
reya
b0fa98831d chore: fix nip4e implementation (#196)
* push

* debug

* disable verify sender

* .
2025-10-28 20:35:34 +07:00
reya
b9297d3a01 chore: follow up on nip-4e (#195)
* update deps

* .

* remove resend button

* clean up

* .

* .

* .

* .

* .
2025-10-28 14:37:30 +07:00
reya
b5ed079a0e chore: improve nip-4e (#194)
* update texts

* update ui

* .

* .
2025-10-27 17:10:38 +07:00
6017eebaed chore: update gpui & components 2025-10-27 08:20:37 +07:00
reya
15bbe82a87 feat: nip4e (#188)
* encryption keys

* .

* .

* move nip4e to device crate

* .

* .

* use i18n for device crate

* refactor

* refactor

* .

* add reset button

* send message with encryption keys

* clean up

* .

* choose signer

* fix

* update i18n

* fix sending
2025-10-26 18:10:40 +07:00
alltheseas
83687e5448 Adds outbound dm relay hint (#193)
* Include relay hints in DM rumor tags

* Add unit test for DM relay hints

---------

Co-authored-by: alltheseas <alltheseas@users.noreply.github.com>
2025-10-25 07:40:55 +07:00
alltheseas
48c90f5bb0 Store DM rumors without re-signing (#192)
Co-authored-by: alltheseas <alltheseas@users.noreply.github.com>
2025-10-25 07:39:02 +07:00
alltheseas
47abd2909b Reject gift wraps whose rumor pubkey doesn’t match the seal signer (#190)
* Verify seal sender before caching rumors

* Test rumor sender verification logic

---------

Co-authored-by: alltheseas <alltheseas@users.noreply.github.com>
2025-10-24 16:30:25 +07:00
reya
ac0b233089 feat: implement multiple keystores (#187)
* keystore

* .

* fix

* .

* allow user disable keyring

* update texts
2025-10-20 07:40:02 +07:00
reya
a1e0934fc3 chore: clean up codebase (#186)
* refactor app state

* clean up

* clean up

* .
2025-10-18 09:46:45 +07:00
32a0401907 chore: simplify codebase 2025-10-17 08:51:34 +07:00
1742031901 chore: update deps 2025-10-12 20:51:46 +07:00
reya
2415374567 chore: improve gossip implementation (#184)
* add send event function

* add set nip17 and set nip65 functions

* setup gossip relays

* .
2025-10-12 20:22:57 +07:00
reya
7fc727461e chore: follow up #181 (#183)
* update deps

* .

* fix
2025-10-11 16:27:14 +07:00
reya
68a8ec7a69 feat: custom gossip implementation (#181)
* .

* rename global to app_state

* refactor event tracker

* gossip

* .

* .
2025-10-10 17:36:38 +07:00
b7693444e6 chore: update deps 2025-10-07 14:45:44 +07:00
6e7f63d79a chore: release version 0.2.11 2025-10-01 13:50:57 +07:00
ee693aa503 chore: update the release script 2025-10-01 13:49:39 +07:00
reya
ebcc60cd92 chore: follow up on #172 (#173)
* clean up

* wip

* clean up

* remove unused picture field
2025-10-01 13:45:13 +07:00
reya
0db48bc003 chore: refactor message sending (#172)
* refactor send message

* refactor resend

* fix

* refactor

* clean up
2025-09-30 08:59:38 +07:00
880ba30d20 chore: bump version 2025-09-28 08:01:41 +07:00
reya
d889f9b25d chore: always call get_public_key on nip46 (#171)
* get public key on login

* .
2025-09-28 07:57:04 +07:00
reya
0de1b20951 feat: add context menu for quick profile viewing (#170)
* add profile context menu

* add context menu for avatar
2025-09-27 15:15:00 +07:00
reya
338a947b57 chore: fix duplicate reply (#169)
* prevent duplicate reply

* .
2025-09-27 08:02:16 +07:00
reya
98ce928f0c chore: fix double message on sent (#166)
* .

* fix

* update
2025-09-26 14:17:31 +07:00
reya
61cad5dd96 chore: refactor the input component (#165)
* refactor the input component

* fix clippy

* clean up
2025-09-25 08:03:14 +07:00
a87184214f chore: add release script 2025-09-23 09:36:49 +07:00
fff3a44f62 chore: bump version 2025-09-23 09:05:35 +07:00
reya
9abcc25f32 chore: optimize resource usage (#162)
* avoid string allocation

* cache image

* .

* .

* .

* fix
2025-09-23 09:03:48 +07:00
reya
fb3da096f8 chore: improve the media uploader (#161)
* refactor upload

* .

* .
2025-09-22 07:30:32 +07:00
1de3045505 chore: update deps 2025-09-19 08:42:08 +07:00
reya
9f369bf57f chore: improve auth handling in startup screen (#160)
* cancel auth

* .
2025-09-18 20:01:10 +07:00
reya
4164651342 chore: refactor the compose modal (#156)
* .

* update

* clean up
2025-09-18 08:39:24 +07:00
c12856cda0 chore: bump version 2025-09-16 20:31:10 +07:00
reya
c67b223a53 chore: add missing ui elements (#153)
* add empty state

* .

* update welcome panel
2025-09-16 19:59:03 +07:00
reya
9880a3ed3d chore: follow up on #151 (#152)
* improve ui

* .

* clean up
2025-09-15 20:53:25 +07:00
reya
d13ffd5a54 feat: detect user dm relays when opening chat panel (#151)
* preconnect to user messaging relays

* .
2025-09-15 19:34:48 +07:00
cc79f0ed1c chore: clean up 2025-09-15 09:10:37 +07:00
reya
5127eaadbb feat: add seen-on-relays viewer per message (#149)
* chore: bump version

* add seen on

* seen on menu
2025-09-14 11:50:14 +07:00
d38e70ecbf chore: update deps 2025-09-13 07:51:33 +07:00
reya
b142982ab1 chore: refactor event fetching (#148)
* use stream for nip65 and nip17 relays fetching

* .
2025-09-13 07:42:17 +07:00
reya
2ea2519e8b feat: resend failed messages (#147)
* .

* .

* fix

* fix

* update

* fix

* .

* .
2025-09-12 17:07:57 +07:00
reya
2ea5feaf4b chore: improve handling of user profiles (#146)
* resubscribe metadata for all pubkeys

* .
2025-09-10 10:06:45 +07:00
4ec7530b91 chore: update deps 2025-09-10 08:21:43 +07:00
df82861101 chore: bump version 2025-09-10 07:25:15 +07:00
reya
fc99ef4dfe chore: improve the activity check (#145)
* better check for activity

* .
2025-09-09 10:23:40 +07:00
reya
d0f7a1abd3 feat: extended screening (#144)
* improve mutual contacts check

* .

* .
2025-09-08 17:11:29 +07:00
reya
71140beb52 feat: relay status viewer (#143)
* add relay status

* .
2025-09-07 14:54:28 +07:00
reya
e177facef4 chore: better handle async tasks (#142)
* improve some codes

* .
2025-09-07 08:33:21 +07:00
60bca49200 chore: update deps and refactor the event loop 2025-09-06 20:55:11 +07:00
reya
ede41c41c3 chore: improve the event loop (#141)
* improve wait for signer

* refactor gift wrap processor

* .

* .

* .

* .

* .
2025-09-05 19:01:26 +07:00
reya
70e235dcc2 chore: minor ui components improvements (#140)
* improve ui

* .

* .
2025-09-04 07:30:03 +07:00
b11b0e0115 chore: update deps 2025-09-03 12:12:40 +07:00
reya
d8edac0bb9 chore: fix rooms out of order while loading (#139)
* fix room out of order while loading

* .

* .
2025-09-03 09:16:36 +07:00
reya
d392602ed6 feat: resend messages after authentication (#137)
* resend failed message

* update settings
2025-09-02 18:19:53 +07:00
reya
5a36354cc8 chore: fix handling of ongoing room kind incorrectly (#136) 2025-09-01 19:44:23 +07:00
a1df66e176 chore: bump hotfix version 2025-09-01 17:31:34 +07:00
reya
78d913ae38 chore: fix high cpu usage and incorrect use of list indices (#135)
* .

* fix cpu usage
2025-09-01 17:30:33 +07:00
b4691aa689 chore: temporary disable room's announcement 2025-09-01 10:16:48 +07:00
c49530b030 chore: bump hotfix version 2025-09-01 08:24:10 +07:00
reya
e7ffe7627c chore: fix double messages on load (#134) 2025-09-01 08:22:04 +07:00
6a5304514f chore: bump version 2025-08-31 19:08:37 +07:00
reya
f2be8fca08 feat: add setting for relay authentication (#133)
* remember auth relay

* .

* .
2025-08-31 18:06:04 +07:00
reya
807851518a feat: manually handle NIP-42 auth request (#132)
* improve fetch relays

* .

* .

* .

* refactor

* refactor

* remove identity

* manually auth

* auth

* prevent duplicate message

* clean up
2025-08-30 14:38:00 +07:00
49a3dedd9c chore: clean up 2025-08-25 13:46:46 +07:00
reya
b19bb01003 feat: support triple-click to select entire line 2025-08-25 12:37:20 +07:00
3a6fc2bcc5 chore: fix messages not loading 2025-08-25 12:13:45 +07:00
reya
5edcc97ada chore: rework login and identity (#129)
* .

* redesign onboarding screen

* .

* add signer proxy

* .

* .

* .

* .

* fix proxy

* clean up

* fix new account
2025-08-25 09:22:09 +07:00
a8ccda259c chore: update deps 2025-08-20 11:50:56 +07:00
reya
23ad28e96e fix cpu spike (#127) 2025-08-19 14:31:13 +07:00
07a2f6980e chore: update deps 2025-08-18 14:24:13 +07:00
reya
c2b276f3f3 chore: improve chat panel (#121)
* .

* .

* .

* skip sent message

* improve sent reports

* .

* .

* .
2025-08-18 13:20:29 +07:00
170 changed files with 22269 additions and 15172 deletions

View File

@@ -157,7 +157,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Release ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.tag }}
draft: true
prerelease: false
generate_release_notes: true

3388
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,11 @@ members = ["crates/*"]
default-members = ["crates/coop"]
[workspace.package]
version = "0.2.2"
version = "0.3.0"
edition = "2021"
publish = false
[workspace.metadata.i18n]
available-locales = ["en"]
default-locale = "en"
load-path = "locales"
[workspace.dependencies]
i18n = { path = "crates/i18n" }
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
@@ -22,33 +16,27 @@ gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr
nostr = { git = "https://github.com/rust-nostr/nostr" }
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
"lmdb",
"nip96",
"nip59",
"nip49",
"nip44",
] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
# Others
anyhow = "1.0.44"
chrono = "0.4.38"
dirs = "5.0"
emojis = "0.6.4"
futures = "0.3"
itertools = "0.13.0"
log = "0.4"
oneshot = "0.1.10"
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
rust-embed = "8.5.0"
rust-i18n = "3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
schemars = "1"
smallvec = "1.14.0"
smol = "2"
tracing = "0.1.40"
webbrowser = "1.0.4"
[profile.release]
strip = true
@@ -56,3 +44,7 @@ opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
[profile.profiling]
inherits = "release"
debug = true

View File

BIN
assets/brand/system.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 21.25H6c-.69 0-1.25-.56-1.25-1.25M11.5 9.25h1M4.75 20V4.75a2 2 0 0 1 2-2h12.5v16H6c-.69 0-1.25.56-1.25 1.25Zm5-6.25s0-1.5 2.25-1.5 2.25 1.5 2.25 1.5h-4.5ZM13 9.25a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 404 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.22-1.53 3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 1 1-1.06 1.06l-1.97-1.97v6.69a.75.75 0 0 1-1.5 0V9.56l-1.97 1.97a.75.75 0 0 1-1.06-1.06Z" clip-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 397 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill="#000" fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10a9.967 9.967 0 0 1-4.098-.876.313.313 0 0 0-.195-.026l-3.471.78a1.75 1.75 0 0 1-2.084-2.12l.809-3.33a.313.313 0 0 0-.028-.204A9.965 9.965 0 0 1 2 12Zm4.5 0a1 1 0 1 0 2 0 1 1 0 0 0-2 0Zm4.5 0a1 1 0 1 0 2 0 1 1 0 0 0-2 0Zm5.5 1a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z" clip-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 488 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M4 4.75A2.75 2.75 0 0 1 6.75 2h10.5A2.75 2.75 0 0 1 20 4.75v7.917a3.9 3.9 0 0 0-4.091.909l-3.75 3.75a2.25 2.25 0 0 0-.659 1.59v2.334c0 .263.045.515.128.75H6.75A2.75 2.75 0 0 1 4 19.25V4.75Z"/>
<path fill="currentColor" fill-rule="evenodd" d="M19.303 15.697a.9.9 0 0 0-1.273 0l-3.53 3.53V20.5h1.273l3.53-3.53a.9.9 0 0 0 0-1.273Zm-2.333-1.06a2.4 2.4 0 1 1 3.394 3.393l-3.75 3.75a.75.75 0 0 1-.53.22H13.75a.75.75 0 0 1-.75-.75v-2.333a.75.75 0 0 1 .22-.53l3.75-3.75Z" clip-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 622 B

4
assets/icons/edit.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.75 21.25h-4a2 2 0 0 1-2-2V4.75a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v7"/>
<path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.5" d="M13.75 21.25v-2.333l3.75-3.75a1.65 1.65 0 0 1 2.333 2.333l-3.75 3.75H13.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8.75a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm0 0v6m8.25-2.838v-4.97a2 2 0 0 0-1.367-1.898l-6.25-2.083a2 2 0 0 0-1.265 0l-6.25 2.083A2 2 0 0 0 3.75 6.942v4.97c0 4.973 4.25 7.338 8.25 9.496 4-2.158 8.25-4.523 8.25-9.496Z"/>
</svg>

After

Width:  |  Height:  |  Size: 425 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M5.75 3A2.75 2.75 0 0 0 3 5.75v1.422c0 .729.29 1.428.805 1.944l4.829 4.829c.234.234.366.552.366.883v6.422a.75.75 0 0 0 .95.723l4.5-1.25A.75.75 0 0 0 15 20v-5.172c0-.331.132-.649.366-.883l4.829-4.829A2.75 2.75 0 0 0 21 7.172V5.75A2.75 2.75 0 0 0 18.25 3H5.75Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 396 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="M18.25 3.75H5.75a2 2 0 0 0-2 2v1.422a2 2 0 0 0 .586 1.414l4.828 4.828a2 2 0 0 1 .586 1.414v6.422l4.5-1.25v-5.172a2 2 0 0 1 .586-1.414l4.828-4.828a2 2 0 0 0 .586-1.414V5.75a2 2 0 0 0-2-2Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 369 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.75 5.75v11.5a2 2 0 0 0 2 2h14.5a2 2 0 0 0 2-2v-8.5a2 2 0 0 0-2-2h-6.18a2 2 0 0 1-1.664-.89l-.812-1.22a2 2 0 0 0-1.664-.89H4.75a2 2 0 0 0-2 2Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 350 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="m21.76 11.45-8.146-7.535a.75.75 0 0 0-1.26.55V8a.51.51 0 0 1-.504.504C3.765 8.632 1.604 11.92 1.604 20.25c1.47-2.94 2.22-4.679 10.245-4.748a.501.501 0 0 1 .505.498v3.535a.75.75 0 0 0 1.26.55l8.145-7.535a.75.75 0 0 0 0-1.1Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 405 B

3
assets/icons/group.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.75 19.25h2.596c1.163 0 2.106-1.001 1.788-2.12-.733-2.573-2.465-4.38-5.134-4.38-.446 0-.866.05-1.26.147M11.25 7a3.25 3.25 0 1 1-6.5 0 3.25 3.25 0 0 1 6.5 0Zm8.5.5a2.75 2.75 0 1 1-5.5 0 2.75 2.75 0 0 1 5.5 0ZM2.08 18.126c.78-3.14 2.78-5.376 5.92-5.376s5.14 2.237 5.918 5.376c.28 1.128-.658 2.124-1.82 2.124H3.901c-1.162 0-2.1-.996-1.82-2.124Z"/>
</svg>

After

Width:  |  Height:  |  Size: 550 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3.75 5.816h8.5M8 5.75v-2m4 10.5C7.935 13.198 5.845 10.614 5.25 6"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 14c4.064-1.02 6.154-3.527 6.75-8m3.594 11.125h5.312m1.594 2.125-3.314-8.774c-.326-.862-1.546-.862-1.872 0L12.75 19.25"/>
</svg>

Before

Width:  |  Height:  |  Size: 494 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M104,152a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H96A8,8,0,0,1,104,152ZM168,32h24a8,8,0,0,0,0-16H160a8,8,0,0,0-8,8V56h16Zm72,84v60a16,16,0,0,1-16,16H136v32a8,8,0,0,1-16,0V192H32a16,16,0,0,1-16-16V116A60.07,60.07,0,0,1,76,56h76v88a8,8,0,0,0,16,0V56h12A60.07,60.07,0,0,1,240,116Zm-120,0a44,44,0,0,0-88,0v60h88Z"></path></svg>

Before

Width:  |  Height:  |  Size: 429 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" d="M12 2a7.795 7.795 0 0 0-7.696 6.554l-1.17 7.258A2.75 2.75 0 0 0 5.848 19h1.66c.849 1.75 2.512 3 4.492 3s3.643-1.25 4.492-3h1.66a2.75 2.75 0 0 0 2.714-3.188l-1.17-7.258A7.795 7.795 0 0 0 12 2Zm2.754 17H9.245c.678.937 1.68 1.5 2.754 1.5s2.076-.563 2.754-1.5Z" clip-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 434 B

4
assets/icons/refresh.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M13 21a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm8-10a1 1 0 1 0-2 0 1 1 0 0 0 2 0Zm-1.07 3.268a1 1 0 1 1-1 1.732 1 1 0 0 1 1-1.732Zm-2.562 5.026a1 1 0 1 0-1-1.732 1 1 0 0 0 1 1.732ZM18.927 8a1 1 0 1 1-1-1.732 1 1 0 0 1 1 1.732Z"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.25 14.75v5.5h-5.5M9 19.688a8.25 8.25 0 1 1 6.25-15.273"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M3 11a8 8 0 1 1 14.162 5.102l3.368 3.368a.75.75 0 1 1-1.06 1.06l-3.368-3.368A8 8 0 0 1 3 11Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 230 B

4
assets/icons/server.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.5" d="M21.25 12V6.75a2 2 0 0 0-2-2H4.75a2 2 0 0 0-2 2V12m18.5 0H2.75m18.5 0v5.25a2 2 0 0 1-2 2H4.75a2 2 0 0 1-2-2V12"/>
<path fill="currentColor" stroke="currentColor" stroke-width=".5" d="M6.5 14.875a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5Zm0-7.25a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m7.5 3.25 4.5 3.5 4.5-3.5m-11.75 17h14.5a2 2 0 0 0 2-2v-9.5a2 2 0 0 0-2-2H4.75a2 2 0 0 0-2 2v9.5a2 2 0 0 0 2 2Z"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 17v2m6-6v6m6-10v10m6-14v14"/>
</svg>

Before

Width:  |  Height:  |  Size: 317 B

After

Width:  |  Height:  |  Size: 233 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" d="M1 11.75A5.75 5.75 0 0 1 6.75 6h10.5A5.75 5.75 0 0 1 23 11.75v.5A5.75 5.75 0 0 1 17.25 18H6.75A5.75 5.75 0 0 1 1 12.25v-.5ZM17 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9Z" clip-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 345 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="1.5" d="M17 17.25H7a5.25 5.25 0 1 1 0-10.5h10m0 10.5a5.25 5.25 0 1 0 0-10.5m0 10.5a5.25 5.25 0 1 1 0-10.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 256 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M64.12,147.8a4,4,0,0,1-4,4.2H16a8,8,0,0,1-7.8-6.17,8.35,8.35,0,0,1,1.62-6.93A67.79,67.79,0,0,1,37,117.51a40,40,0,1,1,66.46-35.8,3.94,3.94,0,0,1-2.27,4.18A64.08,64.08,0,0,0,64,144C64,145.28,64,146.54,64.12,147.8Zm182-8.91A67.76,67.76,0,0,0,219,117.51a40,40,0,1,0-66.46-35.8,3.94,3.94,0,0,0,2.27,4.18A64.08,64.08,0,0,1,192,144c0,1.28,0,2.54-.12,3.8a4,4,0,0,0,4,4.2H240a8,8,0,0,0,7.8-6.17A8.33,8.33,0,0,0,246.17,138.89Zm-89,43.18a48,48,0,1,0-58.37,0A72.13,72.13,0,0,0,65.07,212,8,8,0,0,0,72,224H184a8,8,0,0,0,6.93-12A72.15,72.15,0,0,0,157.19,182.07Z"></path></svg>

Before

Width:  |  Height:  |  Size: 676 B

3
assets/icons/warning.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 9.02v2.993M12 15h.01M10.277 3.99 3.275 15.998C2.499 17.328 3.458 19 4.998 19h14.004c1.54 0 2.5-1.671 1.723-3.002L13.723 3.99c-.77-1.32-2.677-1.32-3.447 0ZM12.25 15a.25.25 0 1 1-.5 0 .25.25 0 0 1 .5 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 384 B

View File

@@ -0,0 +1,136 @@
{
"id": "catppuccin-frappe",
"name": "Catppuccin Frappé",
"author": "Catppuccin",
"url": "https://github.com/catppuccin/catppuccin",
"light": {
"background": "#303446",
"surface_background": "#292c3c",
"elevated_surface_background": "#232634",
"panel_background": "#303446",
"overlay": "#c6d0f51a",
"title_bar": "#00000000",
"title_bar_inactive": "#303446",
"window_border": "#626880",
"border": "#626880",
"border_variant": "#51576d",
"border_focused": "#8caaee",
"border_selected": "#8caaee",
"border_transparent": "#00000000",
"border_disabled": "#414559",
"ring": "#8caaee",
"text": "#c6d0f5",
"text_muted": "#b5bfe2",
"text_placeholder": "#a5adce",
"text_accent": "#8caaee",
"icon": "#c6d0f5",
"icon_muted": "#b5bfe2",
"icon_accent": "#8caaee",
"element_foreground": "#303446",
"element_background": "#8caaee",
"element_hover": "#8caaeee6",
"element_active": "#7e99d6",
"element_selected": "#7088be",
"element_disabled": "#8caaee4d",
"secondary_foreground": "#8caaee",
"secondary_background": "#414559",
"secondary_hover": "#8caaee1a",
"secondary_active": "#51576d",
"secondary_selected": "#51576d",
"secondary_disabled": "#8caaee4d",
"danger_foreground": "#303446",
"danger_background": "#e78284",
"danger_hover": "#e78284e6",
"danger_active": "#d07576",
"danger_selected": "#b96869",
"danger_disabled": "#e782844d",
"warning_foreground": "#303446",
"warning_background": "#e5c890",
"warning_hover": "#e5c890e6",
"warning_active": "#ceb481",
"warning_selected": "#b7a072",
"warning_disabled": "#e5c8904d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#414559",
"ghost_element_hover": "#c6d0f51a",
"ghost_element_active": "#51576d",
"ghost_element_selected": "#51576d",
"ghost_element_disabled": "#c6d0f50d",
"tab_inactive_background": "#414559",
"tab_hover_background": "#51576d",
"tab_active_background": "#626880",
"scrollbar_thumb_background": "#c6d0f533",
"scrollbar_thumb_hover_background": "#c6d0f54d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#51576d",
"drop_target_background": "#8caaee1a",
"cursor": "#99d1db",
"selection": "#99d1db40"
},
"dark": {
"background": "#303446",
"surface_background": "#292c3c",
"elevated_surface_background": "#232634",
"panel_background": "#303446",
"overlay": "#c6d0f51a",
"title_bar": "#00000000",
"title_bar_inactive": "#303446",
"window_border": "#626880",
"border": "#626880",
"border_variant": "#51576d",
"border_focused": "#8caaee",
"border_selected": "#8caaee",
"border_transparent": "#00000000",
"border_disabled": "#414559",
"ring": "#8caaee",
"text": "#c6d0f5",
"text_muted": "#b5bfe2",
"text_placeholder": "#a5adce",
"text_accent": "#8caaee",
"icon": "#c6d0f5",
"icon_muted": "#b5bfe2",
"icon_accent": "#8caaee",
"element_foreground": "#303446",
"element_background": "#8caaee",
"element_hover": "#8caaeee6",
"element_active": "#7e99d6",
"element_selected": "#7088be",
"element_disabled": "#8caaee4d",
"secondary_foreground": "#8caaee",
"secondary_background": "#414559",
"secondary_hover": "#8caaee1a",
"secondary_active": "#51576d",
"secondary_selected": "#51576d",
"secondary_disabled": "#8caaee4d",
"danger_foreground": "#303446",
"danger_background": "#e78284",
"danger_hover": "#e78284e6",
"danger_active": "#d07576",
"danger_selected": "#b96869",
"danger_disabled": "#e782844d",
"warning_foreground": "#303446",
"warning_background": "#e5c890",
"warning_hover": "#e5c890e6",
"warning_active": "#ceb481",
"warning_selected": "#b7a072",
"warning_disabled": "#e5c8904d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#414559",
"ghost_element_hover": "#c6d0f51a",
"ghost_element_active": "#51576d",
"ghost_element_selected": "#51576d",
"ghost_element_disabled": "#c6d0f50d",
"tab_inactive_background": "#414559",
"tab_hover_background": "#51576d",
"tab_active_background": "#626880",
"scrollbar_thumb_background": "#c6d0f533",
"scrollbar_thumb_hover_background": "#c6d0f54d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#51576d",
"drop_target_background": "#8caaee1a",
"cursor": "#99d1db",
"selection": "#99d1db40"
}
}

View File

@@ -0,0 +1,136 @@
{
"id": "catppuccin-latte",
"name": "Catppuccin Latte",
"author": "Catppuccin",
"url": "https://github.com/catppuccin/catppuccin",
"light": {
"background": "#eff1f5",
"surface_background": "#e6e9ef",
"elevated_surface_background": "#dce0e8",
"panel_background": "#eff1f5",
"overlay": "#4c4f691a",
"title_bar": "#00000000",
"title_bar_inactive": "#eff1f5",
"window_border": "#acb0be",
"border": "#acb0be",
"border_variant": "#bcc0cc",
"border_focused": "#1e66f5",
"border_selected": "#1e66f5",
"border_transparent": "#00000000",
"border_disabled": "#ccd0da",
"ring": "#1e66f5",
"text": "#4c4f69",
"text_muted": "#5c5f77",
"text_placeholder": "#6c6f85",
"text_accent": "#1e66f5",
"icon": "#4c4f69",
"icon_muted": "#5c5f77",
"icon_accent": "#1e66f5",
"element_foreground": "#eff1f5",
"element_background": "#1e66f5",
"element_hover": "#1e66f5e6",
"element_active": "#1b5cdc",
"element_selected": "#1852c3",
"element_disabled": "#1e66f54d",
"secondary_foreground": "#1e66f5",
"secondary_background": "#e6e9ef",
"secondary_hover": "#1e66f51a",
"secondary_active": "#dce0e8",
"secondary_selected": "#dce0e8",
"secondary_disabled": "#1e66f54d",
"danger_foreground": "#eff1f5",
"danger_background": "#d20f39",
"danger_hover": "#d20f39e6",
"danger_active": "#bc0e33",
"danger_selected": "#a60c2d",
"danger_disabled": "#d20f394d",
"warning_foreground": "#4c4f69",
"warning_background": "#df8e1d",
"warning_hover": "#df8e1de6",
"warning_active": "#c9801a",
"warning_selected": "#b47217",
"warning_disabled": "#df8e1d4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#e6e9ef",
"ghost_element_hover": "#4c4f691a",
"ghost_element_active": "#dce0e8",
"ghost_element_selected": "#dce0e8",
"ghost_element_disabled": "#4c4f690d",
"tab_inactive_background": "#e6e9ef",
"tab_hover_background": "#dce0e8",
"tab_active_background": "#ccd0da",
"scrollbar_thumb_background": "#4c4f6933",
"scrollbar_thumb_hover_background": "#4c4f694d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#dce0e8",
"drop_target_background": "#1e66f51a",
"cursor": "#04a5e5",
"selection": "#04a5e540"
},
"dark": {
"background": "#eff1f5",
"surface_background": "#e6e9ef",
"elevated_surface_background": "#dce0e8",
"panel_background": "#eff1f5",
"overlay": "#4c4f691a",
"title_bar": "#00000000",
"title_bar_inactive": "#eff1f5",
"window_border": "#acb0be",
"border": "#acb0be",
"border_variant": "#bcc0cc",
"border_focused": "#1e66f5",
"border_selected": "#1e66f5",
"border_transparent": "#00000000",
"border_disabled": "#ccd0da",
"ring": "#1e66f5",
"text": "#4c4f69",
"text_muted": "#5c5f77",
"text_placeholder": "#6c6f85",
"text_accent": "#1e66f5",
"icon": "#4c4f69",
"icon_muted": "#5c5f77",
"icon_accent": "#1e66f5",
"element_foreground": "#eff1f5",
"element_background": "#1e66f5",
"element_hover": "#1e66f5e6",
"element_active": "#1b5cdc",
"element_selected": "#1852c3",
"element_disabled": "#1e66f54d",
"secondary_foreground": "#1e66f5",
"secondary_background": "#e6e9ef",
"secondary_hover": "#1e66f51a",
"secondary_active": "#dce0e8",
"secondary_selected": "#dce0e8",
"secondary_disabled": "#1e66f54d",
"danger_foreground": "#eff1f5",
"danger_background": "#d20f39",
"danger_hover": "#d20f39e6",
"danger_active": "#bc0e33",
"danger_selected": "#a60c2d",
"danger_disabled": "#d20f394d",
"warning_foreground": "#4c4f69",
"warning_background": "#df8e1d",
"warning_hover": "#df8e1de6",
"warning_active": "#c9801a",
"warning_selected": "#b47217",
"warning_disabled": "#df8e1d4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#e6e9ef",
"ghost_element_hover": "#4c4f691a",
"ghost_element_active": "#dce0e8",
"ghost_element_selected": "#dce0e8",
"ghost_element_disabled": "#4c4f690d",
"tab_inactive_background": "#e6e9ef",
"tab_hover_background": "#dce0e8",
"tab_active_background": "#ccd0da",
"scrollbar_thumb_background": "#4c4f6933",
"scrollbar_thumb_hover_background": "#4c4f694d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#dce0e8",
"drop_target_background": "#1e66f51a",
"cursor": "#04a5e5",
"selection": "#04a5e540"
}
}

View File

@@ -0,0 +1,136 @@
{
"id": "catppuccin-macchiato",
"name": "Catppuccin Macchiato",
"author": "Catppuccin",
"url": "https://github.com/catppuccin/catppuccin",
"light": {
"background": "#24273a",
"surface_background": "#1e2030",
"elevated_surface_background": "#181926",
"panel_background": "#24273a",
"overlay": "#cad3f51a",
"title_bar": "#00000000",
"title_bar_inactive": "#24273a",
"window_border": "#5b6078",
"border": "#5b6078",
"border_variant": "#494d64",
"border_focused": "#8aadf4",
"border_selected": "#8aadf4",
"border_transparent": "#00000000",
"border_disabled": "#363a4f",
"ring": "#8aadf4",
"text": "#cad3f5",
"text_muted": "#b8c0e0",
"text_placeholder": "#a5adcb",
"text_accent": "#8aadf4",
"icon": "#cad3f5",
"icon_muted": "#b8c0e0",
"icon_accent": "#8aadf4",
"element_foreground": "#24273a",
"element_background": "#8aadf4",
"element_hover": "#8aadf4e6",
"element_active": "#7c9cdc",
"element_selected": "#6e8bc4",
"element_disabled": "#8aadf44d",
"secondary_foreground": "#8aadf4",
"secondary_background": "#363a4f",
"secondary_hover": "#8aadf41a",
"secondary_active": "#494d64",
"secondary_selected": "#494d64",
"secondary_disabled": "#8aadf44d",
"danger_foreground": "#24273a",
"danger_background": "#ed8796",
"danger_hover": "#ed8796e6",
"danger_active": "#d57a87",
"danger_selected": "#bd6d78",
"danger_disabled": "#ed87964d",
"warning_foreground": "#24273a",
"warning_background": "#eed49f",
"warning_hover": "#eed49fe6",
"warning_active": "#d6bf8f",
"warning_selected": "#beaa7f",
"warning_disabled": "#eed49f4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#363a4f",
"ghost_element_hover": "#cad3f51a",
"ghost_element_active": "#494d64",
"ghost_element_selected": "#494d64",
"ghost_element_disabled": "#cad3f50d",
"tab_inactive_background": "#363a4f",
"tab_hover_background": "#494d64",
"tab_active_background": "#5b6078",
"scrollbar_thumb_background": "#cad3f533",
"scrollbar_thumb_hover_background": "#cad3f54d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#494d64",
"drop_target_background": "#8aadf41a",
"cursor": "#91d7e3",
"selection": "#91d7e340"
},
"dark": {
"background": "#24273a",
"surface_background": "#1e2030",
"elevated_surface_background": "#181926",
"panel_background": "#24273a",
"overlay": "#cad3f51a",
"title_bar": "#00000000",
"title_bar_inactive": "#24273a",
"window_border": "#5b6078",
"border": "#5b6078",
"border_variant": "#494d64",
"border_focused": "#8aadf4",
"border_selected": "#8aadf4",
"border_transparent": "#00000000",
"border_disabled": "#363a4f",
"ring": "#8aadf4",
"text": "#cad3f5",
"text_muted": "#b8c0e0",
"text_placeholder": "#a5adcb",
"text_accent": "#8aadf4",
"icon": "#cad3f5",
"icon_muted": "#b8c0e0",
"icon_accent": "#8aadf4",
"element_foreground": "#24273a",
"element_background": "#8aadf4",
"element_hover": "#8aadf4e6",
"element_active": "#7c9cdc",
"element_selected": "#6e8bc4",
"element_disabled": "#8aadf44d",
"secondary_foreground": "#8aadf4",
"secondary_background": "#363a4f",
"secondary_hover": "#8aadf41a",
"secondary_active": "#494d64",
"secondary_selected": "#494d64",
"secondary_disabled": "#8aadf44d",
"danger_foreground": "#24273a",
"danger_background": "#ed8796",
"danger_hover": "#ed8796e6",
"danger_active": "#d57a87",
"danger_selected": "#bd6d78",
"danger_disabled": "#ed87964d",
"warning_foreground": "#24273a",
"warning_background": "#eed49f",
"warning_hover": "#eed49fe6",
"warning_active": "#d6bf8f",
"warning_selected": "#beaa7f",
"warning_disabled": "#eed49f4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#363a4f",
"ghost_element_hover": "#cad3f51a",
"ghost_element_active": "#494d64",
"ghost_element_selected": "#494d64",
"ghost_element_disabled": "#cad3f50d",
"tab_inactive_background": "#363a4f",
"tab_hover_background": "#494d64",
"tab_active_background": "#5b6078",
"scrollbar_thumb_background": "#cad3f533",
"scrollbar_thumb_hover_background": "#cad3f54d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#494d64",
"drop_target_background": "#8aadf41a",
"cursor": "#91d7e3",
"selection": "#91d7e340"
}
}

View File

@@ -0,0 +1,136 @@
{
"id": "catppuccin-mocha",
"name": "Catppuccin Mocha",
"author": "Catppuccin",
"url": "https://github.com/catppuccin/catppuccin",
"light": {
"background": "#1e1e2e",
"surface_background": "#181825",
"elevated_surface_background": "#11111b",
"panel_background": "#1e1e2e",
"overlay": "#cdd6f41a",
"title_bar": "#00000000",
"title_bar_inactive": "#1e1e2e",
"window_border": "#585b70",
"border": "#585b70",
"border_variant": "#45475a",
"border_focused": "#89b4fa",
"border_selected": "#89b4fa",
"border_transparent": "#00000000",
"border_disabled": "#313244",
"ring": "#89b4fa",
"text": "#cdd6f4",
"text_muted": "#bac2de",
"text_placeholder": "#a6adc8",
"text_accent": "#89b4fa",
"icon": "#cdd6f4",
"icon_muted": "#bac2de",
"icon_accent": "#89b4fa",
"element_foreground": "#1e1e2e",
"element_background": "#89b4fa",
"element_hover": "#89b4fae6",
"element_active": "#7ba2e1",
"element_selected": "#6d90c8",
"element_disabled": "#89b4fa4d",
"secondary_foreground": "#89b4fa",
"secondary_background": "#313244",
"secondary_hover": "#89b4fa1a",
"secondary_active": "#45475a",
"secondary_selected": "#45475a",
"secondary_disabled": "#89b4fa4d",
"danger_foreground": "#1e1e2e",
"danger_background": "#f38ba8",
"danger_hover": "#f38ba8e6",
"danger_active": "#db7d97",
"danger_selected": "#c36f86",
"danger_disabled": "#f38ba84d",
"warning_foreground": "#1e1e2e",
"warning_background": "#f9e2af",
"warning_hover": "#f9e2afe6",
"warning_active": "#e0cb9e",
"warning_selected": "#c7b48d",
"warning_disabled": "#f9e2af4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#313244",
"ghost_element_hover": "#cdd6f41a",
"ghost_element_active": "#45475a",
"ghost_element_selected": "#45475a",
"ghost_element_disabled": "#cdd6f50d",
"tab_inactive_background": "#313244",
"tab_hover_background": "#45475a",
"tab_active_background": "#585b70",
"scrollbar_thumb_background": "#cdd6f533",
"scrollbar_thumb_hover_background": "#cdd6f54d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#45475a",
"drop_target_background": "#89b4fa1a",
"cursor": "#89dceb",
"selection": "#89dceb40"
},
"dark": {
"background": "#1e1e2e",
"surface_background": "#181825",
"elevated_surface_background": "#11111b",
"panel_background": "#1e1e2e",
"overlay": "#cdd6f41a",
"title_bar": "#00000000",
"title_bar_inactive": "#1e1e2e",
"window_border": "#585b70",
"border": "#585b70",
"border_variant": "#45475a",
"border_focused": "#89b4fa",
"border_selected": "#89b4fa",
"border_transparent": "#00000000",
"border_disabled": "#313244",
"ring": "#89b4fa",
"text": "#cdd6f4",
"text_muted": "#bac2de",
"text_placeholder": "#a6adc8",
"text_accent": "#89b4fa",
"icon": "#cdd6f4",
"icon_muted": "#bac2de",
"icon_accent": "#89b4fa",
"element_foreground": "#1e1e2e",
"element_background": "#89b4fa",
"element_hover": "#89b4fae6",
"element_active": "#7ba2e1",
"element_selected": "#6d90c8",
"element_disabled": "#89b4fa4d",
"secondary_foreground": "#89b4fa",
"secondary_background": "#313244",
"secondary_hover": "#89b4fa1a",
"secondary_active": "#45475a",
"secondary_selected": "#45475a",
"secondary_disabled": "#89b4fa4d",
"danger_foreground": "#1e1e2e",
"danger_background": "#f38ba8",
"danger_hover": "#f38ba8e6",
"danger_active": "#db7d97",
"danger_selected": "#c36f86",
"danger_disabled": "#f38ba84d",
"warning_foreground": "#1e1e2e",
"warning_background": "#f9e2af",
"warning_hover": "#f9e2afe6",
"warning_active": "#e0cb9e",
"warning_selected": "#c7b48d",
"warning_disabled": "#f9e2af4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#313244",
"ghost_element_hover": "#cdd6f41a",
"ghost_element_active": "#45475a",
"ghost_element_selected": "#45475a",
"ghost_element_disabled": "#cdd6f50d",
"tab_inactive_background": "#313244",
"tab_hover_background": "#45475a",
"tab_active_background": "#585b70",
"scrollbar_thumb_background": "#cdd6f533",
"scrollbar_thumb_hover_background": "#cdd6f54d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#45475a",
"drop_target_background": "#89b4fa1a",
"cursor": "#89dceb",
"selection": "#89dceb40"
}
}

136
assets/themes/flexoki.json Normal file
View File

@@ -0,0 +1,136 @@
{
"id": "flexoki",
"name": "Flexoki",
"author": "Steph Ango",
"url": "https://stephango.com/flexoki",
"light": {
"background": "#FFFCF0",
"surface_background": "#F2F0E5",
"elevated_surface_background": "#E6E4D9",
"panel_background": "#FFFCF0",
"overlay": "#100F0F1a",
"title_bar": "#00000000",
"title_bar_inactive": "#FFFCF0",
"window_border": "#CECDC3",
"border": "#CECDC3",
"border_variant": "#DAD8CE",
"border_focused": "#24837B",
"border_selected": "#24837B",
"border_transparent": "#00000000",
"border_disabled": "#E6E4D9",
"ring": "#24837B",
"text": "#100F0F",
"text_muted": "#6F6E69",
"text_placeholder": "#878580",
"text_accent": "#24837B",
"icon": "#100F0F",
"icon_muted": "#6F6E69",
"icon_accent": "#24837B",
"element_foreground": "#DDF1E4",
"element_background": "#24837B",
"element_hover": "#24837Be5",
"element_active": "#20756E",
"element_selected": "#1C6861",
"element_disabled": "#24837B4c",
"secondary_foreground": "#24837B",
"secondary_background": "#E6E4D9",
"secondary_hover": "#24837B1a",
"secondary_active": "#DAD8CE",
"secondary_selected": "#DAD8CE",
"secondary_disabled": "#24837B4c",
"danger_foreground": "#FFE1D5",
"danger_background": "#AF3029",
"danger_hover": "#AF3029e5",
"danger_active": "#9E2B25",
"danger_selected": "#8D2620",
"danger_disabled": "#AF30294c",
"warning_foreground": "#FFE7CE",
"warning_background": "#BC5215",
"warning_hover": "#BC5215e5",
"warning_active": "#A94913",
"warning_selected": "#964011",
"warning_disabled": "#BC52154c",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#E6E4D9",
"ghost_element_hover": "#100F0F1a",
"ghost_element_active": "#DAD8CE",
"ghost_element_selected": "#DAD8CE",
"ghost_element_disabled": "#100F0F0d",
"tab_inactive_background": "#E6E4D9",
"tab_hover_background": "#DAD8CE",
"tab_active_background": "#CECDC3",
"scrollbar_thumb_background": "#100F0F33",
"scrollbar_thumb_hover_background": "#100F0F4d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#DAD8CE",
"drop_target_background": "#24837B1a",
"cursor": "#205EA6",
"selection": "#24837B40"
},
"dark": {
"background": "#100F0F",
"surface_background": "#1C1B1A",
"elevated_surface_background": "#282726",
"panel_background": "#100F0F",
"overlay": "#FFFCF01a",
"title_bar": "#00000000",
"title_bar_inactive": "#100F0F",
"window_border": "#403E3C",
"border": "#403E3C",
"border_variant": "#343331",
"border_focused": "#3AA99F",
"border_selected": "#3AA99F",
"border_transparent": "#00000000",
"border_disabled": "#282726",
"ring": "#3AA99F",
"text": "#FFFCF0",
"text_muted": "#878580",
"text_placeholder": "#575653",
"text_accent": "#3AA99F",
"icon": "#FFFCF0",
"icon_muted": "#878580",
"icon_accent": "#3AA99F",
"element_foreground": "#101F1D",
"element_background": "#3AA99F",
"element_hover": "#3AA99Fe5",
"element_active": "#34988F",
"element_selected": "#2F877F",
"element_disabled": "#3AA99F4c",
"secondary_foreground": "#3AA99F",
"secondary_background": "#282726",
"secondary_hover": "#3AA99F1a",
"secondary_active": "#343331",
"secondary_selected": "#343331",
"secondary_disabled": "#3AA99F4c",
"danger_foreground": "#261312",
"danger_background": "#D14D41",
"danger_hover": "#D14D41e5",
"danger_active": "#BC453A",
"danger_selected": "#A73D33",
"danger_disabled": "#D14D414c",
"warning_foreground": "#27180E",
"warning_background": "#DA702C",
"warning_hover": "#DA702Ce5",
"warning_active": "#C46527",
"warning_selected": "#AF5A22",
"warning_disabled": "#DA702C4c",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#282726",
"ghost_element_hover": "#FFFCF01a",
"ghost_element_active": "#343331",
"ghost_element_selected": "#343331",
"ghost_element_disabled": "#FFFCF00d",
"tab_inactive_background": "#282726",
"tab_hover_background": "#343331",
"tab_active_background": "#403E3C",
"scrollbar_thumb_background": "#FFFCF033",
"scrollbar_thumb_hover_background": "#FFFCF04d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#343331",
"drop_target_background": "#3AA99F1a",
"cursor": "#4385BE",
"selection": "#3AA99F40"
}
}

View File

@@ -0,0 +1,136 @@
{
"id": "rose-pine-dawn",
"name": "Rosé Pine Dawn",
"author": "Rosé Pine",
"url": "https://rosepinetheme.com/",
"light": {
"background": "#faf4ed",
"surface_background": "#fffaf3",
"elevated_surface_background": "#f2e9e1",
"panel_background": "#fffaf3",
"overlay": "#5752791a",
"title_bar": "#00000000",
"title_bar_inactive": "#faf4ed",
"window_border": "#cecacd",
"border": "#cecacd",
"border_variant": "#dfdad9",
"border_focused": "#286983",
"border_selected": "#286983",
"border_transparent": "#00000000",
"border_disabled": "#f4ede8",
"ring": "#286983",
"text": "#575279",
"text_muted": "#797593",
"text_placeholder": "#9893a5",
"text_accent": "#907aa9",
"icon": "#575279",
"icon_muted": "#797593",
"icon_accent": "#907aa9",
"element_foreground": "#faf4ed",
"element_background": "#286983",
"element_hover": "#286983e6",
"element_active": "#245f76",
"element_selected": "#205569",
"element_disabled": "#2869834d",
"secondary_foreground": "#286983",
"secondary_background": "#f4ede8",
"secondary_hover": "#2869831a",
"secondary_active": "#dfdad9",
"secondary_selected": "#dfdad9",
"secondary_disabled": "#2869834d",
"danger_foreground": "#faf4ed",
"danger_background": "#b4637a",
"danger_hover": "#b4637ae6",
"danger_active": "#a2596e",
"danger_selected": "#904f62",
"danger_disabled": "#b4637a4d",
"warning_foreground": "#faf4ed",
"warning_background": "#ea9d34",
"warning_hover": "#ea9d34e6",
"warning_active": "#d38d2f",
"warning_selected": "#bc7d2a",
"warning_disabled": "#ea9d344d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#f4ede8",
"ghost_element_hover": "#5752791a",
"ghost_element_active": "#dfdad9",
"ghost_element_selected": "#dfdad9",
"ghost_element_disabled": "#5752790d",
"tab_inactive_background": "#f4ede8",
"tab_hover_background": "#dfdad9",
"tab_active_background": "#cecacd",
"scrollbar_thumb_background": "#57527933",
"scrollbar_thumb_hover_background": "#5752794d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#dfdad9",
"drop_target_background": "#2869831a",
"cursor": "#56949f",
"selection": "#56949f40"
},
"dark": {
"background": "#faf4ed",
"surface_background": "#fffaf3",
"elevated_surface_background": "#f2e9e1",
"panel_background": "#fffaf3",
"overlay": "#5752791a",
"title_bar": "#00000000",
"title_bar_inactive": "#faf4ed",
"window_border": "#cecacd",
"border": "#cecacd",
"border_variant": "#dfdad9",
"border_focused": "#286983",
"border_selected": "#286983",
"border_transparent": "#00000000",
"border_disabled": "#f4ede8",
"ring": "#286983",
"text": "#575279",
"text_muted": "#797593",
"text_placeholder": "#9893a5",
"text_accent": "#907aa9",
"icon": "#575279",
"icon_muted": "#797593",
"icon_accent": "#907aa9",
"element_foreground": "#faf4ed",
"element_background": "#286983",
"element_hover": "#286983e6",
"element_active": "#245f76",
"element_selected": "#205569",
"element_disabled": "#2869834d",
"secondary_foreground": "#286983",
"secondary_background": "#f4ede8",
"secondary_hover": "#2869831a",
"secondary_active": "#dfdad9",
"secondary_selected": "#dfdad9",
"secondary_disabled": "#2869834d",
"danger_foreground": "#faf4ed",
"danger_background": "#b4637a",
"danger_hover": "#b4637ae6",
"danger_active": "#a2596e",
"danger_selected": "#904f62",
"danger_disabled": "#b4637a4d",
"warning_foreground": "#faf4ed",
"warning_background": "#ea9d34",
"warning_hover": "#ea9d34e6",
"warning_active": "#d38d2f",
"warning_selected": "#bc7d2a",
"warning_disabled": "#ea9d344d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#f4ede8",
"ghost_element_hover": "#5752791a",
"ghost_element_active": "#dfdad9",
"ghost_element_selected": "#dfdad9",
"ghost_element_disabled": "#5752790d",
"tab_inactive_background": "#f4ede8",
"tab_hover_background": "#dfdad9",
"tab_active_background": "#cecacd",
"scrollbar_thumb_background": "#57527933",
"scrollbar_thumb_hover_background": "#5752794d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#dfdad9",
"drop_target_background": "#2869831a",
"cursor": "#56949f",
"selection": "#56949f40"
}
}

View File

@@ -0,0 +1,136 @@
{
"id": "rose-pine-moon",
"name": "Rosé Pine Moon",
"author": "Rosé Pine",
"url": "https://rosepinetheme.com/",
"light": {
"background": "#232136",
"surface_background": "#2a273f",
"elevated_surface_background": "#393552",
"panel_background": "#2a273f",
"overlay": "#e0def41a",
"title_bar": "#00000000",
"title_bar_inactive": "#232136",
"window_border": "#56526e",
"border": "#56526e",
"border_variant": "#44415a",
"border_focused": "#3e8fb0",
"border_selected": "#3e8fb0",
"border_transparent": "#00000000",
"border_disabled": "#2a283e",
"ring": "#3e8fb0",
"text": "#e0def4",
"text_muted": "#908caa",
"text_placeholder": "#6e6a86",
"text_accent": "#c4a7e7",
"icon": "#e0def4",
"icon_muted": "#908caa",
"icon_accent": "#c4a7e7",
"element_foreground": "#232136",
"element_background": "#3e8fb0",
"element_hover": "#3e8fb0e6",
"element_active": "#38809d",
"element_selected": "#32718a",
"element_disabled": "#3e8fb04d",
"secondary_foreground": "#3e8fb0",
"secondary_background": "#2a283e",
"secondary_hover": "#3e8fb01a",
"secondary_active": "#44415a",
"secondary_selected": "#44415a",
"secondary_disabled": "#3e8fb04d",
"danger_foreground": "#232136",
"danger_background": "#eb6f92",
"danger_hover": "#eb6f92e6",
"danger_active": "#d46483",
"danger_selected": "#bd5974",
"danger_disabled": "#eb6f924d",
"warning_foreground": "#232136",
"warning_background": "#f6c177",
"warning_hover": "#f6c177e6",
"warning_active": "#ddae6b",
"warning_selected": "#c49b5f",
"warning_disabled": "#f6c1774d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#2a283e",
"ghost_element_hover": "#e0def41a",
"ghost_element_active": "#44415a",
"ghost_element_selected": "#44415a",
"ghost_element_disabled": "#e0def40d",
"tab_inactive_background": "#2a283e",
"tab_hover_background": "#44415a",
"tab_active_background": "#56526e",
"scrollbar_thumb_background": "#e0def433",
"scrollbar_thumb_hover_background": "#e0def44d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#44415a",
"drop_target_background": "#3e8fb01a",
"cursor": "#9ccfd8",
"selection": "#9ccfd840"
},
"dark": {
"background": "#232136",
"surface_background": "#2a273f",
"elevated_surface_background": "#393552",
"panel_background": "#2a273f",
"overlay": "#e0def41a",
"title_bar": "#00000000",
"title_bar_inactive": "#232136",
"window_border": "#56526e",
"border": "#56526e",
"border_variant": "#44415a",
"border_focused": "#3e8fb0",
"border_selected": "#3e8fb0",
"border_transparent": "#00000000",
"border_disabled": "#2a283e",
"ring": "#3e8fb0",
"text": "#e0def4",
"text_muted": "#908caa",
"text_placeholder": "#6e6a86",
"text_accent": "#c4a7e7",
"icon": "#e0def4",
"icon_muted": "#908caa",
"icon_accent": "#c4a7e7",
"element_foreground": "#232136",
"element_background": "#3e8fb0",
"element_hover": "#3e8fb0e6",
"element_active": "#38809d",
"element_selected": "#32718a",
"element_disabled": "#3e8fb04d",
"secondary_foreground": "#3e8fb0",
"secondary_background": "#2a283e",
"secondary_hover": "#3e8fb01a",
"secondary_active": "#44415a",
"secondary_selected": "#44415a",
"secondary_disabled": "#3e8fb04d",
"danger_foreground": "#232136",
"danger_background": "#eb6f92",
"danger_hover": "#eb6f92e6",
"danger_active": "#d46483",
"danger_selected": "#bd5974",
"danger_disabled": "#eb6f924d",
"warning_foreground": "#232136",
"warning_background": "#f6c177",
"warning_hover": "#f6c177e6",
"warning_active": "#ddae6b",
"warning_selected": "#c49b5f",
"warning_disabled": "#f6c1774d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#2a283e",
"ghost_element_hover": "#e0def41a",
"ghost_element_active": "#44415a",
"ghost_element_selected": "#44415a",
"ghost_element_disabled": "#e0def40d",
"tab_inactive_background": "#2a283e",
"tab_hover_background": "#44415a",
"tab_active_background": "#56526e",
"scrollbar_thumb_background": "#e0def433",
"scrollbar_thumb_hover_background": "#e0def44d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#44415a",
"drop_target_background": "#3e8fb01a",
"cursor": "#9ccfd8",
"selection": "#9ccfd840"
}
}

View File

@@ -0,0 +1,136 @@
{
"id": "rose-pine",
"name": "Rosé Pine",
"author": "Rosé Pine",
"url": "https://rosepinetheme.com/",
"light": {
"background": "#191724",
"surface_background": "#1f1d2e",
"elevated_surface_background": "#26233a",
"panel_background": "#1f1d2e",
"overlay": "#e0def41a",
"title_bar": "#00000000",
"title_bar_inactive": "#191724",
"window_border": "#524f67",
"border": "#524f67",
"border_variant": "#403d52",
"border_focused": "#31748f",
"border_selected": "#31748f",
"border_transparent": "#00000000",
"border_disabled": "#21202e",
"ring": "#31748f",
"text": "#e0def4",
"text_muted": "#908caa",
"text_placeholder": "#6e6a86",
"text_accent": "#c4a7e7",
"icon": "#e0def4",
"icon_muted": "#908caa",
"icon_accent": "#c4a7e7",
"element_foreground": "#191724",
"element_background": "#31748f",
"element_hover": "#31748fe6",
"element_active": "#2c6980",
"element_selected": "#275e71",
"element_disabled": "#31748f4d",
"secondary_foreground": "#31748f",
"secondary_background": "#21202e",
"secondary_hover": "#31748f1a",
"secondary_active": "#403d52",
"secondary_selected": "#403d52",
"secondary_disabled": "#31748f4d",
"danger_foreground": "#191724",
"danger_background": "#eb6f92",
"danger_hover": "#eb6f92e6",
"danger_active": "#d46483",
"danger_selected": "#bd5974",
"danger_disabled": "#eb6f924d",
"warning_foreground": "#191724",
"warning_background": "#f6c177",
"warning_hover": "#f6c177e6",
"warning_active": "#ddae6b",
"warning_selected": "#c49b5f",
"warning_disabled": "#f6c1774d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#21202e",
"ghost_element_hover": "#e0def41a",
"ghost_element_active": "#403d52",
"ghost_element_selected": "#403d52",
"ghost_element_disabled": "#e0def40d",
"tab_inactive_background": "#21202e",
"tab_hover_background": "#403d52",
"tab_active_background": "#524f67",
"scrollbar_thumb_background": "#e0def433",
"scrollbar_thumb_hover_background": "#e0def44d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#403d52",
"drop_target_background": "#31748f1a",
"cursor": "#9ccfd8",
"selection": "#9ccfd840"
},
"dark": {
"background": "#191724",
"surface_background": "#1f1d2e",
"elevated_surface_background": "#26233a",
"panel_background": "#1f1d2e",
"overlay": "#e0def41a",
"title_bar": "#00000000",
"title_bar_inactive": "#191724",
"window_border": "#524f67",
"border": "#524f67",
"border_variant": "#403d52",
"border_focused": "#31748f",
"border_selected": "#31748f",
"border_transparent": "#00000000",
"border_disabled": "#21202e",
"ring": "#31748f",
"text": "#e0def4",
"text_muted": "#908caa",
"text_placeholder": "#6e6a86",
"text_accent": "#c4a7e7",
"icon": "#e0def4",
"icon_muted": "#908caa",
"icon_accent": "#c4a7e7",
"element_foreground": "#191724",
"element_background": "#31748f",
"element_hover": "#31748fe6",
"element_active": "#2c6980",
"element_selected": "#275e71",
"element_disabled": "#31748f4d",
"secondary_foreground": "#31748f",
"secondary_background": "#21202e",
"secondary_hover": "#31748f1a",
"secondary_active": "#403d52",
"secondary_selected": "#403d52",
"secondary_disabled": "#31748f4d",
"danger_foreground": "#191724",
"danger_background": "#eb6f92",
"danger_hover": "#eb6f92e6",
"danger_active": "#d46483",
"danger_selected": "#bd5974",
"danger_disabled": "#eb6f924d",
"warning_foreground": "#191724",
"warning_background": "#f6c177",
"warning_hover": "#f6c177e6",
"warning_active": "#ddae6b",
"warning_selected": "#c49b5f",
"warning_disabled": "#f6c1774d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#21202e",
"ghost_element_hover": "#e0def41a",
"ghost_element_active": "#403d52",
"ghost_element_selected": "#403d52",
"ghost_element_disabled": "#e0def40d",
"tab_inactive_background": "#21202e",
"tab_hover_background": "#403d52",
"tab_active_background": "#524f67",
"scrollbar_thumb_background": "#e0def433",
"scrollbar_thumb_hover_background": "#e0def44d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#403d52",
"drop_target_background": "#31748f1a",
"cursor": "#9ccfd8",
"selection": "#9ccfd840"
}
}

View File

@@ -5,8 +5,9 @@ use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "../../assets"]
#[include = "fonts/**/*"]
#[include = "brand/*"]
#[include = "brand/**/*"]
#[include = "icons/**/*"]
#[include = "themes/**/*"]
#[exclude = "*.DS_Store"]
pub struct Assets;
@@ -47,13 +48,4 @@ impl Assets {
cx.text_system().add_fonts(embedded_fonts)
}
pub fn load_test_fonts(&self, cx: &App) {
cx.text_system()
.add_fonts(vec![self
.load("fonts/plex-mono/ZedPlexMono-Regular.ttf")
.unwrap()
.unwrap()])
.unwrap()
}
}

View File

@@ -6,13 +6,16 @@ publish.workspace = true
[dependencies]
common = { path = "../common" }
global = { path = "../global" }
state = { path = "../state" }
gpui.workspace = true
gpui_tokio.workspace = true
reqwest.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smol.workspace = true
log.workspace = true
smallvec.workspace = true
cargo-packager-updater = "0.2.3"
semver = "1.0.27"
tempfile = "3.23.0"

View File

@@ -1,10 +1,22 @@
use anyhow::Error;
use cargo_packager_updater::semver::Version;
use cargo_packager_updater::{check_update, Config, Update};
use global::constants::{APP_PUBKEY, APP_UPDATER_ENDPOINT};
use gpui::http_client::Url;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error};
use common::BOOTSTRAP_RELAYS;
use gpui::http_client::{AsyncBody, HttpClient};
use gpui::{
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
};
use nostr_sdk::prelude::*;
use semver::Version;
use smallvec::{smallvec, SmallVec};
use smol::fs::File;
use smol::process::Command;
use state::NostrRegistry;
const APP_PUBKEY: &str = "npub1y9jvl5vznq49eh9f2gj7679v4042kj80lp7p8fte3ql2cr7hty7qsyca8q";
pub fn init(cx: &mut App) {
AutoUpdater::set_global(cx.new(AutoUpdater::new), cx);
@@ -14,16 +26,101 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {}
#[derive(Debug, Clone)]
#[cfg(not(target_os = "windows"))]
struct InstallerDir(tempfile::TempDir);
#[cfg(not(target_os = "windows"))]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
Ok(Self(
tempfile::Builder::new()
.prefix("coop-auto-update")
.tempdir()?,
))
}
fn path(&self) -> &Path {
self.0.path()
}
}
#[cfg(target_os = "windows")]
struct InstallerDir(PathBuf);
#[cfg(target_os = "windows")]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
let installer_dir = std::env::current_exe()?
.parent()
.context("No parent dir for Coop.exe")?
.join("updates");
if smol::fs::metadata(&installer_dir).await.is_ok() {
smol::fs::remove_dir_all(&installer_dir).await?;
}
smol::fs::create_dir(&installer_dir).await?;
Ok(Self(installer_dir))
}
fn path(&self) -> &Path {
self.0.as_path()
}
}
struct MacOsUnmounter<'a> {
mount_path: PathBuf,
background_executor: &'a BackgroundExecutor,
}
impl Drop for MacOsUnmounter<'_> {
fn drop(&mut self) {
let mount_path = std::mem::take(&mut self.mount_path);
self.background_executor
.spawn(async move {
let unmount_output = Command::new("hdiutil")
.args(["detach", "-force"])
.arg(&mount_path)
.output()
.await;
match unmount_output {
Ok(output) if output.status.success() => {
log::info!("Successfully unmounted the disk image");
}
Ok(output) => {
log::error!(
"Failed to unmount disk image: {:?}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(error) => {
log::error!("Error while trying to unmount disk image: {:?}", error);
}
}
})
.detach();
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum AutoUpdateStatus {
Idle,
Checking,
Checked { update: Box<Update> },
Checked { files: Vec<EventId> },
Installing,
Updated,
Errored { msg: Box<String> },
}
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
fn as_ref(&self) -> &AutoUpdateStatus {
self
}
}
impl AutoUpdateStatus {
pub fn is_updating(&self) -> bool {
matches!(self, Self::Checked { .. } | Self::Installing)
@@ -33,10 +130,8 @@ impl AutoUpdateStatus {
matches!(self, Self::Updated)
}
pub fn checked(update: Update) -> Self {
Self::Checked {
update: Box::new(update),
}
pub fn checked(files: Vec<EventId>) -> Self {
Self::Checked { files }
}
pub fn error(e: String) -> Self {
@@ -44,112 +139,89 @@ impl AutoUpdateStatus {
}
}
#[derive(Debug)]
pub struct AutoUpdater {
/// Current status of the auto updater
pub status: AutoUpdateStatus,
config: Config,
version: Version,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
/// Current version of the application
pub version: Version,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
_tasks: SmallVec<[Task<()>; 2]>,
}
impl AutoUpdater {
/// Retrieve the Global Auto Updater instance
/// Retrieve the global auto updater instance
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAutoUpdater>().0.clone()
}
/// Retrieve the Auto Updater instance
pub fn read_global(cx: &App) -> &Self {
cx.global::<GlobalAutoUpdater>().0.read(cx)
}
/// Set the Global Auto Updater instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
/// Set the global auto updater instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAutoUpdater(state));
}
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let config = cargo_packager_updater::Config {
endpoints: vec![Url::parse(APP_UPDATER_ENDPOINT).expect("Endpoint is not valid")],
pubkey: String::from(APP_PUBKEY),
..Default::default()
};
let version = Version::parse(env!("CARGO_PKG_VERSION")).expect("Failed to parse version");
let mut subscriptions = smallvec![];
fn new(cx: &mut Context<Self>) -> Self {
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
let async_version = version.clone();
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
if let Some(window) = window {
this.check_for_updates(window, cx);
}
}));
let mut subscriptions = smallvec![];
let mut tasks = smallvec![];
tasks.push(
// Subscribe to get the new update event in the bootstrap relays
Self::subscribe_to_updates(cx),
);
tasks.push(
// Subscribe to get the new update event in the bootstrap relays
cx.spawn(async move |this, cx| {
// Check for updates after 2 minutes
cx.background_executor()
.timer(Duration::from_secs(120))
.await;
// Update the status to checking
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Checking, cx);
});
match Self::check_for_updates(async_version, cx).await {
Ok(ids) => {
// Update the status to downloading
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::checked(ids), cx);
});
}
Err(e) => {
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Idle, cx);
});
log::warn!("{e}");
}
}
}),
);
subscriptions.push(
// Observe the status
cx.observe_self(|this, cx| {
if let AutoUpdateStatus::Checked { files } = this.status.clone() {
this.get_latest_release(&files, cx);
}
}),
);
Self {
status: AutoUpdateStatus::Idle,
version,
config,
subscriptions,
}
}
pub fn check_for_updates(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let config = self.config.clone();
let current_version = self.version.clone();
log::info!("Checking for updates...");
self.set_status(AutoUpdateStatus::Checking, cx);
let checking: Task<Result<Option<Update>, Error>> = cx.background_spawn(async move {
if let Some(update) = check_update(current_version, config)? {
Ok(Some(update))
} else {
Ok(None)
}
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some(update)) = checking.await {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::checked(update), cx);
this.install_update(window, cx);
})
.ok();
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Idle, cx);
})
.ok();
}
})
.detach();
}
pub(crate) fn install_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_status(AutoUpdateStatus::Installing, cx);
if let AutoUpdateStatus::Checked { update } = self.status.clone() {
let install: Task<Result<(), Error>> =
cx.background_spawn(async move { Ok(update.download_and_install()?) });
cx.spawn_in(window, async move |this, cx| {
match install.await {
Ok(_) => {
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Updated, cx);
})
.ok();
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
})
.ok();
}
};
})
.detach();
_subscriptions: subscriptions,
_tasks: tasks,
}
}
@@ -157,4 +229,266 @@ impl AutoUpdater {
self.status = status;
cx.notify();
}
fn subscribe_to_updates(cx: &App) -> Task<()> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
let filter = Filter::new()
.kind(Kind::ReleaseArtifactSet)
.author(app_pubkey)
.limit(1);
if let Err(e) = client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
{
log::error!("Failed to subscribe to updates: {e}");
};
})
}
fn check_for_updates(version: Version, cx: &AsyncApp) -> Task<Result<Vec<EventId>, Error>> {
let client = cx.update(|cx| {
let nostr = NostrRegistry::global(cx);
nostr.read(cx).client()
});
cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
let filter = Filter::new()
.kind(Kind::ReleaseArtifactSet)
.author(app_pubkey)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
let new_version: Version = event
.tags
.find(TagKind::d())
.and_then(|tag| tag.content())
.and_then(|content| content.split("@").last())
.and_then(|content| Version::parse(content).ok())
.context("Failed to parse version")?;
if new_version > version {
// Get all file metadata event ids
let ids: Vec<EventId> = event.tags.event_ids().copied().collect();
let filter = Filter::new()
.kind(Kind::FileMetadata)
.author(app_pubkey)
.ids(ids.clone());
// Get all files for this release
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await?;
Ok(ids)
} else {
Err(anyhow!("No update available"))
}
} else {
Err(anyhow!("No update available"))
}
})
}
fn get_latest_release(&mut self, ids: &[EventId], cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let http_client = cx.http_client();
let ids = ids.to_vec();
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
let os = std::env::consts::OS;
let filter = Filter::new()
.kind(Kind::FileMetadata)
.author(app_pubkey)
.ids(ids);
// Get all urls for this release
let events = client.database().query(filter).await?;
for event in events.into_iter() {
// Only process events that match current platform
if event.content != os {
continue;
}
// Parse the url
let url = event
.tags
.find(TagKind::Url)
.and_then(|tag| tag.content())
.and_then(|content| Url::parse(content).ok())
.context("Failed to parse url")?;
let installer_dir = InstallerDir::new().await?;
let target_path = Self::target_path(&installer_dir).await?;
// Download the release
download(url.as_str(), &target_path, http_client).await?;
return Ok((installer_dir, target_path));
}
Err(anyhow!("Failed to get latest release"))
});
self._tasks.push(
// Install the new release
cx.spawn(async move |this, cx| {
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Installing, cx);
});
match task.await {
Ok((installer_dir, target_path)) => {
if Self::install(installer_dir, target_path, cx).await.is_ok() {
// Update the status to updated
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Updated, cx);
});
}
}
Err(e) => {
// Update the status to error including the error message
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
});
}
}
}),
);
}
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
let filename = match std::env::consts::OS {
"macos" => anyhow::Ok("Coop.dmg"),
"windows" => Ok("Coop.exe"),
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
}?;
Ok(installer_dir.path().join(filename))
}
async fn install(
installer_dir: InstallerDir,
target_path: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
match std::env::consts::OS {
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
"windows" => install_release_windows(target_path).await,
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
}
}
}
async fn download(
url: &str,
target_path: &std::path::Path,
client: Arc<dyn HttpClient>,
) -> Result<(), Error> {
let body = AsyncBody::default();
let mut target_file = File::create(&target_path).await?;
let mut response = client.get(url, body, true).await?;
// Copy the response body to the target file
smol::io::copy(response.body_mut(), &mut target_file).await?;
Ok(())
}
async fn install_release_macos(
temp_dir: &InstallerDir,
downloaded_dmg: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
let running_app_path = cx.update(|cx| cx.app_path())?;
let running_app_filename = running_app_path
.file_name()
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
let mount_path = temp_dir.path().join("Coop");
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
mounted_app_path.push("/");
let output = Command::new("hdiutil")
.args(["attach", "-nobrowse"])
.arg(&downloaded_dmg)
.arg("-mountroot")
.arg(temp_dir.path())
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to mount: {:?}",
String::from_utf8_lossy(&output.stderr)
);
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
let _unmounter = MacOsUnmounter {
mount_path: mount_path.clone(),
background_executor: cx.background_executor(),
};
let output = Command::new("rsync")
.args(["-av", "--delete"])
.arg(&mounted_app_path)
.arg(&running_app_path)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to copy app: {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
//const CREATE_NO_WINDOW: u32 = 0x08000000;
let system_root = std::env::var("SYSTEMROOT");
let powershell_path = system_root.as_ref().map_or_else(
|_| "powershell.exe".to_string(),
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
);
let mut installer_path = std::ffi::OsString::new();
installer_path.push("\"");
installer_path.push(&downloaded_installer);
installer_path.push("\"");
let output = Command::new(powershell_path)
//.creation_flags(CREATE_NO_WINDOW)
.args(["-NoProfile", "-WindowStyle", "Hidden"])
.args(["Start-Process"])
.arg(installer_path)
.arg("-ArgumentList")
.args(["/P", "/R"])
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to start installer: {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}

View File

@@ -1,25 +1,27 @@
[package]
name = "registry"
name = "chat"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
global = { path = "../global" }
state = { path = "../state" }
device = { path = "../device" }
person = { path = "../person" }
settings = { path = "../settings" }
rust-i18n.workspace = true
i18n.workspace = true
gpui.workspace = true
nostr.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
chrono.workspace = true
smallvec.workspace = true
smol.workspace = true
oneshot.workspace = true
log.workspace = true
futures.workspace = true
flume.workspace = true
serde.workspace = true
serde_json.workspace = true
fuzzy-matcher = "0.3.7"

718
crates/chat/src/lib.rs Normal file
View File

@@ -0,0 +1,718 @@
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error};
use common::EventUtils;
use device::DeviceRegistry;
use flume::Sender;
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity,
};
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use state::{tracker, NostrRegistry, GIFTWRAP_SUBSCRIPTION};
mod message;
mod room;
pub use message::*;
pub use room::*;
pub fn init(cx: &mut App) {
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
}
struct GlobalChatRegistry(Entity<ChatRegistry>);
impl Global for GlobalChatRegistry {}
/// Chat event.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum ChatEvent {
/// An event to open a room by its ID
OpenRoom(u64),
/// An event to close a room by its ID
CloseRoom(u64),
/// An event to notify UI about a new chat request
Ping,
}
/// Channel signal.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum NostrEvent {
/// Message received from relay pool
Message(NewMessage),
/// Unwrapping status
Unwrapping(bool),
/// Eose received from relay pool
Eose,
}
/// Chat Registry
#[derive(Debug)]
pub struct ChatRegistry {
/// Collection of all chat rooms
rooms: Vec<Entity<Room>>,
/// Loading status of the registry
loading: bool,
/// Tracking the status of unwrapping gift wrap events.
tracking_flag: Arc<AtomicBool>,
/// Channel's sender for communication between nostr and gpui
sender: Sender<NostrEvent>,
/// Handle notifications asynchronous task
notifications: Option<Task<Result<(), Error>>>,
/// Tasks for asynchronous operations
tasks: Vec<Task<()>>,
/// Subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl EventEmitter<ChatEvent> for ChatRegistry {}
impl ChatRegistry {
/// Retrieve the global chat registry state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalChatRegistry>().0.clone()
}
/// Set the global chat registry instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalChatRegistry(state));
}
/// Create a new chat registry instance
fn new(cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let identity = nostr.read(cx).identity();
let device = DeviceRegistry::global(cx);
let device_signer = device.read(cx).device_signer.clone();
// A flag to indicate if the registry is loading
let tracking_flag = Arc::new(AtomicBool::new(true));
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<NostrEvent>(2048);
let mut tasks = vec![];
let mut subscriptions = smallvec![];
subscriptions.push(
// Observe the identity
cx.observe(&identity, |this, state, cx| {
if state.read(cx).has_public_key() {
// Handle nostr notifications
this.handle_notifications(cx);
// Track unwrapping progress
this.tracking(cx);
}
}),
);
subscriptions.push(
// Observe the device signer state
cx.observe(&device_signer, |this, state, cx| {
if state.read(cx).is_some() {
this.handle_notifications(cx);
}
}),
);
tasks.push(
// Update GPUI states
cx.spawn(async move |this, cx| {
while let Ok(message) = rx.recv_async().await {
match message {
NostrEvent::Message(message) => {
this.update(cx, |this, cx| {
this.new_message(message, cx);
})
.ok();
}
NostrEvent::Eose => {
this.update(cx, |this, cx| {
this.get_rooms(cx);
})
.ok();
}
NostrEvent::Unwrapping(status) => {
this.update(cx, |this, cx| {
this.set_loading(status, cx);
this.get_rooms(cx);
})
.ok();
}
};
}
}),
);
Self {
rooms: vec![],
loading: true,
tracking_flag,
sender: tx.clone(),
notifications: None,
tasks,
_subscriptions: subscriptions,
}
}
/// Handle nostr notifications
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let device = DeviceRegistry::global(cx);
let device_signer = device.read(cx).signer(cx);
let status = self.tracking_flag.clone();
let tx = self.sender.clone();
self.tasks.push(cx.background_spawn(async move {
let initialized_at = Timestamp::now();
let subscription_id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
while let Ok(notification) = notifications.recv().await {
let RelayPoolNotification::Message { message, .. } = notification else {
// Skip non-message notifications
continue;
};
match message {
RelayMessage::Event { event, .. } => {
if !processed_events.insert(event.id) {
// Skip if the event has already been processed
continue;
}
if event.kind != Kind::GiftWrap {
// Skip non-gift wrap events
continue;
}
// Extract the rumor from the gift wrap event
match Self::extract_rumor(&client, &device_signer, event.as_ref()).await {
Ok(rumor) => match rumor.created_at >= initialized_at {
true => {
// Check if the event is sent by coop
let sent_by_coop = {
let tracker = tracker().read().await;
tracker.is_sent_by_coop(&event.id)
};
// No need to emit if sent by coop
// the event is already emitted
if !sent_by_coop {
let new_message = NewMessage::new(event.id, rumor);
let signal = NostrEvent::Message(new_message);
tx.send_async(signal).await.ok();
}
}
false => {
status.store(true, Ordering::Release);
}
},
Err(e) => {
log::warn!("Failed to unwrap: {e}");
}
}
}
RelayMessage::EndOfStoredEvents(id) => {
if id.as_ref() == &subscription_id {
tx.send_async(NostrEvent::Eose).await.ok();
}
}
_ => {}
}
}
}));
}
/// Tracking the status of unwrapping gift wrap events.
fn tracking(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let status = self.tracking_flag.clone();
let tx = self.sender.clone();
self.notifications = Some(cx.background_spawn(async move {
let loop_duration = Duration::from_secs(12);
let mut is_start_processing = false;
let mut total_loops = 0;
loop {
if client.has_signer().await {
total_loops += 1;
if status.load(Ordering::Acquire) {
is_start_processing = true;
// Reset gift wrap processing flag
_ = status.compare_exchange(
true,
false,
Ordering::Release,
Ordering::Relaxed,
);
tx.send_async(NostrEvent::Unwrapping(true)).await.ok();
} else {
// Only run further if we are already processing
// Wait until after 2 loops to prevent exiting early while events are still being processed
if is_start_processing && total_loops >= 2 {
tx.send_async(NostrEvent::Unwrapping(false)).await.ok();
// Reset the counter
is_start_processing = false;
total_loops = 0;
}
}
}
smol::Timer::after(loop_duration).await;
}
}));
}
/// Get the loading status of the chat registry
pub fn loading(&self) -> bool {
self.loading
}
/// Set the loading status of the chat registry
pub fn set_loading(&mut self, loading: bool, cx: &mut Context<Self>) {
self.loading = loading;
cx.notify();
}
/// Get a weak reference to a room by its ID.
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
self.rooms
.iter()
.find(|this| &this.read(cx).id == id)
.map(|this| this.downgrade())
}
/// Get all ongoing rooms.
pub fn ongoing_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).kind == RoomKind::Ongoing)
.cloned()
.collect()
}
/// Get all request rooms.
pub fn request_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).kind != RoomKind::Ongoing)
.cloned()
.collect()
}
/// Add a new room to the start of list.
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
where
I: Into<Room>,
{
self.rooms.insert(0, cx.new(|_| room.into()));
cx.notify();
}
/// Emit an open room event.
/// If the room is new, add it to the registry.
pub fn emit_room(&mut self, room: WeakEntity<Room>, cx: &mut Context<Self>) {
if let Some(room) = room.upgrade() {
let id = room.read(cx).id;
// If the room is new, add it to the registry.
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
self.rooms.insert(0, room);
}
// Emit the open room event.
cx.emit(ChatEvent::OpenRoom(id));
}
}
/// Close a room.
pub fn close_room(&mut self, id: u64, cx: &mut Context<Self>) {
if self.rooms.iter().any(|r| r.read(cx).id == id) {
cx.emit(ChatEvent::CloseRoom(id));
}
}
/// Sort rooms by their created at.
pub fn sort(&mut self, cx: &mut Context<Self>) {
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
cx.notify();
}
/// Search rooms by their name.
pub fn search(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
let matcher = SkimMatcherV2::default();
self.rooms
.iter()
.filter(|room| {
matcher
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
.is_some()
})
.cloned()
.collect()
}
/// Search rooms by public keys.
pub fn search_by_public_key(&self, public_key: PublicKey, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).members.contains(&public_key))
.cloned()
.collect()
}
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms.clear();
cx.notify();
}
/// Extend the registry with new rooms.
fn extend_rooms(&mut self, rooms: HashSet<Room>, cx: &mut Context<Self>) {
let mut room_map: HashMap<u64, usize> = self
.rooms
.iter()
.enumerate()
.map(|(idx, room)| (room.read(cx).id, idx))
.collect();
for new_room in rooms.into_iter() {
// Check if we already have a room with this ID
if let Some(&index) = room_map.get(&new_room.id) {
self.rooms[index].update(cx, |this, cx| {
if new_room.created_at > this.created_at {
*this = new_room;
cx.notify();
}
});
} else {
let new_room_id = new_room.id;
self.rooms.push(cx.new(|_| new_room));
let new_index = self.rooms.len();
room_map.insert(new_room_id, new_index);
}
}
}
/// Load all rooms from the database.
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
let task = self.create_get_rooms_task(cx);
self.tasks.push(
// Run and finished in the background
cx.spawn(async move |this, cx| {
match task.await {
Ok(rooms) => {
this.update(cx, move |this, cx| {
this.extend_rooms(rooms, cx);
this.sort(cx);
})
.expect("Entity has been released");
}
Err(e) => {
log::error!("Failed to load rooms: {e}")
}
};
}),
);
}
/// Create a task to load rooms from the database
fn create_get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get the contact bypass setting
let bypass_setting = AppSettings::get_contact_bypass(cx);
cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let contacts = client.database().contacts_public_keys(public_key).await?;
let authored_filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
// Get all authored events
let authored = client.database().query(authored_filter).await?;
let addressed_filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key);
// Get all addressed events
let addressed = client.database().query(addressed_filter).await?;
// Merge authored and addressed events
let events = authored.merge(addressed);
let mut rooms: HashSet<Room> = HashSet::new();
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
// Process each event and group by room hash
for raw in events.into_iter() {
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) {
if rumor.tags.public_keys().peekable().peek().is_some() {
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
}
}
}
for (_id, mut messages) in grouped.into_iter() {
messages.sort_by_key(|m| Reverse(m.created_at));
let Some(latest) = messages.first() else {
continue;
};
let mut room = Room::from(latest);
if rooms.iter().any(|r| r.id == room.id) {
continue;
}
let mut public_keys = room.members();
public_keys.retain(|pk| pk != &public_key);
// Check if the user has responded to the room
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
// Determine if the room is ongoing or not
let mut bypassed = false;
// Check if public keys are from the user's contacts
if bypass_setting {
bypassed = public_keys.iter().any(|k| contacts.contains(k));
}
// Set the room's kind based on status
if user_sent || bypassed {
room = room.kind(RoomKind::Ongoing);
}
rooms.insert(room);
}
Ok(rooms)
})
}
/// Trigger a refresh of the opened chat rooms by their IDs
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
if let Some(ids) = ids {
for room in self.rooms.iter() {
if ids.contains(&room.read(cx).id) {
room.update(cx, |this, cx| {
this.emit_refresh(cx);
});
}
}
}
}
/// Parse a Nostr event into a Coop Message and push it to the belonging room
///
/// If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
// Get the unique id
let id = message.rumor.uniq_id();
// Get the author
let author = message.rumor.pubkey;
match self.rooms.iter().find(|room| room.read(cx).id == id) {
Some(room) => {
let new_message = message.rumor.created_at > room.read(cx).created_at;
let created_at = message.rumor.created_at;
// Update room
room.update(cx, |this, cx| {
// Update the last timestamp if the new message is newer
if new_message {
this.set_created_at(created_at, cx);
}
// Set this room is ongoing if the new message is from current user
if author == nostr.read(cx).identity().read(cx).public_key() {
this.set_ongoing(cx);
}
// Emit the new message to the room
this.emit_message(message, cx);
});
// Resort all rooms in the registry by their created at (after updated)
if new_message {
self.sort(cx);
}
}
None => {
// Push the new room to the front of the list
self.add_room(&message.rumor, cx);
// Notify the UI about the new room
cx.emit(ChatEvent::Ping);
}
}
}
// Unwraps a gift-wrapped event and processes its contents.
async fn extract_rumor(
client: &Client,
device_signer: &Option<Arc<dyn NostrSigner>>,
gift_wrap: &Event,
) -> Result<UnsignedEvent, Error> {
// Try to get cached rumor first
if let Ok(event) = Self::get_rumor(client, gift_wrap.id).await {
return Ok(event);
}
// Try to unwrap with the available signer
let unwrapped = Self::try_unwrap(client, device_signer, gift_wrap).await?;
let mut rumor_unsigned = unwrapped.rumor;
// Generate event id for the rumor if it doesn't have one
rumor_unsigned.ensure_id();
// Cache the rumor
Self::set_rumor(client, gift_wrap.id, &rumor_unsigned).await?;
Ok(rumor_unsigned)
}
// Helper method to try unwrapping with different signers
async fn try_unwrap(
client: &Client,
device_signer: &Option<Arc<dyn NostrSigner>>,
gift_wrap: &Event,
) -> Result<UnwrappedGift, Error> {
if let Some(signer) = device_signer.as_ref() {
let seal = signer
.nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content)
.await?;
let seal: Event = Event::from_json(seal)?;
seal.verify_with_ctx(&SECP256K1)?;
let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?;
let rumor = UnsignedEvent::from_json(rumor)?;
return Ok(UnwrappedGift {
sender: seal.pubkey,
rumor,
});
}
let signer = client.signer().await?;
let unwrapped = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?;
Ok(unwrapped)
}
/// Stores an unwrapped event in local database with reference to original
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
let author = rumor.pubkey;
let conversation = Self::conversation_id(rumor);
let mut tags = rumor.tags.clone().to_vec();
// Add a unique identifier
tags.push(Tag::identifier(id));
// Add a reference to the rumor's author
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)),
[author],
));
// Add a conversation id
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)),
[conversation.to_string()],
));
// Add a reference to the rumor's id
tags.push(Tag::event(rumor_id));
// Add references to the rumor's participants
for receiver in rumor.tags.public_keys().copied() {
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)),
[receiver],
));
}
// Convert rumor to json
let content = rumor.as_json();
// Construct the event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tags(tags)
.sign(&Keys::generate())
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Retrieves a previously unwrapped event from local database
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(gift_wrap)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
} else {
Err(anyhow!("Event is not cached yet."))
}
}
/// Get the conversation ID for a given rumor (message).
fn conversation_id(rumor: &UnsignedEvent) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().copied().collect();
pubkeys.push(rumor.pubkey);
pubkeys.sort();
pubkeys.dedup();
pubkeys.hash(&mut hasher);
hasher.finish()
}
}

212
crates/chat/src/message.rs Normal file
View File

@@ -0,0 +1,212 @@
use std::hash::Hash;
use nostr_sdk::prelude::*;
/// New message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NewMessage {
pub gift_wrap: EventId,
pub rumor: UnsignedEvent,
}
impl NewMessage {
pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
Self { gift_wrap, rumor }
}
}
/// Message.
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum Message {
User(RenderedMessage),
Warning(String, Timestamp),
System(Timestamp),
}
impl Message {
pub fn user<I>(user: I) -> Self
where
I: Into<RenderedMessage>,
{
Self::User(user.into())
}
pub fn warning<I>(content: I) -> Self
where
I: Into<String>,
{
Self::Warning(content.into(), Timestamp::now())
}
pub fn system() -> Self {
Self::System(Timestamp::default())
}
fn timestamp(&self) -> &Timestamp {
match self {
Message::User(msg) => &msg.created_at,
Message::Warning(_, ts) => ts,
Message::System(ts) => ts,
}
}
}
impl From<&NewMessage> for Message {
fn from(val: &NewMessage) -> Self {
Self::User(val.into())
}
}
impl From<&UnsignedEvent> for Message {
fn from(val: &UnsignedEvent) -> Self {
Self::User(val.into())
}
}
impl Ord for Message {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match (self, other) {
// System always comes first
(Message::System(_), Message::System(_)) => self.timestamp().cmp(other.timestamp()),
(Message::System(_), _) => std::cmp::Ordering::Less,
(_, Message::System(_)) => std::cmp::Ordering::Greater,
// For non-system messages, compare by timestamp
_ => self.timestamp().cmp(other.timestamp()),
}
}
}
impl PartialOrd for Message {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
/// Rendered message.
#[derive(Debug, Clone)]
pub struct RenderedMessage {
pub id: EventId,
/// Author's public key
pub author: PublicKey,
/// The content/text of the message
pub content: String,
/// Message created time as unix timestamp
pub created_at: Timestamp,
/// List of mentioned public keys in the message
pub mentions: Vec<PublicKey>,
/// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>,
}
impl From<&Event> for RenderedMessage {
fn from(val: &Event) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
Self {
id: val.id,
author: val.pubkey,
content: val.content.clone(),
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&UnsignedEvent> for RenderedMessage {
fn from(val: &UnsignedEvent) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
Self {
// Event ID must be known
id: val.id.unwrap(),
author: val.pubkey,
content: val.content.clone(),
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&NewMessage> for RenderedMessage {
fn from(val: &NewMessage) -> Self {
let mentions = extract_mentions(&val.rumor.content);
let replies_to = extract_reply_ids(&val.rumor.tags);
Self {
// Event ID must be known
id: val.rumor.id.unwrap(),
author: val.rumor.pubkey,
content: val.rumor.content.clone(),
created_at: val.rumor.created_at,
mentions,
replies_to,
}
}
}
impl Eq for RenderedMessage {}
impl PartialEq for RenderedMessage {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Ord for RenderedMessage {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.created_at.cmp(&other.created_at)
}
}
impl PartialOrd for RenderedMessage {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Hash for RenderedMessage {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
/// Extracts all mentions (public keys) from a content string.
fn extract_mentions(content: &str) -> Vec<PublicKey> {
let parser = NostrParser::new();
let tokens = parser.parse(content);
tokens
.filter_map(|token| match token {
Token::Nostr(nip21) => match nip21 {
Nip21::Pubkey(pubkey) => Some(pubkey),
Nip21::Profile(profile) => Some(profile.public_key),
_ => None,
},
_ => None,
})
.collect::<Vec<_>>()
}
/// Extracts all reply (ids) from the event tags.
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
let mut replies_to = vec![];
for tag in inner.filter(TagKind::e()) {
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
replies_to.push(id);
}
}
for tag in inner.filter(TagKind::q()) {
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
replies_to.push(id);
}
}
replies_to
}

625
crates/chat/src/room.rs Normal file
View File

@@ -0,0 +1,625 @@
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::time::Duration;
use anyhow::Error;
use common::EventUtils;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use state::{tracker, NostrRegistry};
use crate::NewMessage;
const SEND_RETRY: usize = 10;
#[derive(Debug, Clone)]
pub struct SendReport {
pub receiver: PublicKey,
pub status: Option<Output<EventId>>,
pub error: Option<SharedString>,
pub on_hold: Option<Event>,
pub encryption: bool,
pub relays_not_found: bool,
pub device_not_found: bool,
}
impl SendReport {
pub fn new(receiver: PublicKey) -> Self {
Self {
receiver,
status: None,
error: None,
on_hold: None,
encryption: false,
relays_not_found: false,
device_not_found: false,
}
}
pub fn status(mut self, output: Output<EventId>) -> Self {
self.status = Some(output);
self
}
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
self.error = Some(error.into());
self
}
pub fn on_hold(mut self, event: Event) -> Self {
self.on_hold = Some(event);
self
}
pub fn encryption(mut self) -> Self {
self.encryption = true;
self
}
pub fn relays_not_found(mut self) -> Self {
self.relays_not_found = true;
self
}
pub fn device_not_found(mut self) -> Self {
self.device_not_found = true;
self
}
pub fn is_relay_error(&self) -> bool {
self.error.is_some() || self.relays_not_found
}
pub fn is_sent_success(&self) -> bool {
if let Some(output) = self.status.as_ref() {
!output.success.is_empty()
} else {
false
}
}
}
/// Room event.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum RoomEvent {
/// Incoming message.
Incoming(NewMessage),
/// Reloads the current room's messages.
Reload,
}
/// Room kind.
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum RoomKind {
#[default]
Request,
Ongoing,
}
#[derive(Debug)]
pub struct Room {
/// Conversation ID
pub id: u64,
/// The timestamp of the last message in the room
pub created_at: Timestamp,
/// Subject of the room
pub subject: Option<SharedString>,
/// All members of the room
pub members: Vec<PublicKey>,
/// Kind
pub kind: RoomKind,
}
impl Ord for Room {
fn cmp(&self, other: &Self) -> Ordering {
self.created_at.cmp(&other.created_at)
}
}
impl PartialOrd for Room {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for Room {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Hash for Room {
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
impl Eq for Room {}
impl EventEmitter<RoomEvent> for Room {}
impl From<&UnsignedEvent> for Room {
fn from(val: &UnsignedEvent) -> Self {
let id = val.uniq_id();
let created_at = val.created_at;
// Get the members from the event's tags and event's pubkey
let members = val.extract_public_keys();
// Get subject from tags
let subject = val
.tags
.find(TagKind::Subject)
.and_then(|tag| tag.content().map(|s| s.to_owned().into()));
Room {
id,
created_at,
subject,
members,
kind: RoomKind::default(),
}
}
}
impl Room {
/// Constructs a new room with the given receiver and tags.
pub fn new(subject: Option<String>, author: PublicKey, receivers: Vec<PublicKey>) -> Self {
// Convert receiver's public keys into tags
let mut tags: Tags = Tags::from_list(
receivers
.iter()
.map(|pubkey| Tag::public_key(pubkey.to_owned()))
.collect(),
);
// Add subject if it is present
if let Some(subject) = subject {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
subject,
)));
}
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
.tags(tags)
.build(author);
// Generate event ID
event.ensure_id();
Room::from(&event)
}
/// Sets the kind of the room and returns the modified room
pub fn kind(mut self, kind: RoomKind) -> Self {
self.kind = kind;
self
}
/// Sets this room is ongoing conversation
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
if self.kind != RoomKind::Ongoing {
self.kind = RoomKind::Ongoing;
cx.notify();
}
}
/// Updates the creation timestamp of the room
pub fn set_created_at(&mut self, created_at: impl Into<Timestamp>, cx: &mut Context<Self>) {
self.created_at = created_at.into();
cx.notify();
}
/// Updates the subject of the room
pub fn set_subject<T>(&mut self, subject: T, cx: &mut Context<Self>)
where
T: Into<SharedString>,
{
self.subject = Some(subject.into());
cx.notify();
}
/// Returns the members of the room
pub fn members(&self) -> Vec<PublicKey> {
self.members.clone()
}
/// Returns the members of the room with their messaging relays
pub fn members_with_relays(&self, cx: &App) -> Task<Vec<(PublicKey, Vec<RelayUrl>)>> {
let nostr = NostrRegistry::global(cx);
let mut tasks = vec![];
for member in self.members.iter() {
let task = nostr.read(cx).messaging_relays(member, cx);
tasks.push((*member, task));
}
cx.background_spawn(async move {
let mut results = vec![];
for (public_key, task) in tasks.into_iter() {
let urls = task.await;
results.push((public_key, urls));
}
results
})
}
/// Checks if the room has more than two members (group)
pub fn is_group(&self) -> bool {
self.members.len() > 2
}
/// Gets the display name for the room
pub fn display_name(&self, cx: &App) -> SharedString {
if let Some(value) = self.subject.clone() {
value
} else {
self.merged_name(cx)
}
}
/// Gets the display image for the room
pub fn display_image(&self, cx: &App) -> SharedString {
if !self.is_group() {
self.display_member(cx).avatar()
} else {
SharedString::from("brand/group.png")
}
}
/// Get a member to represent the room
///
/// Display member is always different from the current user.
pub fn display_member(&self, cx: &App) -> Person {
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let public_key = nostr.read(cx).identity().read(cx).public_key();
let target_member = self
.members
.iter()
.find(|&member| member != &public_key)
.or_else(|| self.members.first())
.expect("Room should have at least one member");
persons.read(cx).get(target_member, cx)
}
/// Merge the names of the first two members of the room.
fn merged_name(&self, cx: &App) -> SharedString {
let persons = PersonRegistry::global(cx);
if self.is_group() {
let profiles: Vec<Person> = self
.members
.iter()
.map(|public_key| persons.read(cx).get(public_key, cx))
.collect();
let mut name = profiles
.iter()
.take(2)
.map(|p| p.name())
.collect::<Vec<_>>()
.join(", ");
if profiles.len() > 2 {
name = format!("{}, +{}", name, profiles.len() - 2);
}
SharedString::from(name)
} else {
self.display_member(cx).name()
}
}
/// Emits a new message signal to the current room
pub fn emit_message(&self, message: NewMessage, cx: &mut Context<Self>) {
cx.emit(RoomEvent::Incoming(message));
}
/// Emits a signal to reload the current room's messages.
pub fn emit_refresh(&mut self, cx: &mut Context<Self>) {
cx.emit(RoomEvent::Reload);
}
/// Get gossip relays for each member
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let members = self.members();
let id = SubscriptionId::new(format!("room-{}", self.id));
cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
// Subscription options
let opts = SubscribeAutoCloseOptions::default()
.timeout(Some(Duration::from_secs(2)))
.exit_policy(ReqExitPolicy::ExitOnEOSE);
for member in members.into_iter() {
if member == public_key {
continue;
};
// Construct a filter for gossip relays
let filter = Filter::new().kind(Kind::RelayList).author(member).limit(1);
// Subscribe to get member's gossip relays
client
.subscribe_with_id(id.clone(), filter, Some(opts))
.await?;
}
Ok(())
})
}
/// Get all messages belonging to the room
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let conversation_id = self.id.to_string();
cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
let messages = client
.database()
.query(filter)
.await?
.into_iter()
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
.sorted_by_key(|message| message.created_at)
.collect();
Ok(messages)
})
}
/// Create a new message event (unsigned)
pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent {
let nostr = NostrRegistry::global(cx);
// Get current user
let public_key = nostr.read(cx).identity().read(cx).public_key();
// Get room's subject
let subject = self.subject.clone();
let mut tags = vec![];
// Add receivers
//
// NOTE: current user will be removed from the list of receivers
for member in self.members.iter() {
// Get relay hint if available
let relay_url = nostr.read(cx).relay_hint(member, cx);
// Construct a public key tag with relay hint
let tag = TagStandard::PublicKey {
public_key: member.to_owned(),
relay_url,
alias: None,
uppercase: false,
};
tags.push(Tag::from_standardized_without_cell(tag));
}
// Add subject tag if it's present
if let Some(value) = subject {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
value.to_string(),
)));
}
// Add reply/quote tag
if replies.len() == 1 {
tags.push(Tag::event(replies[0]))
} else {
for id in replies {
let tag = TagStandard::Quote {
event_id: id.to_owned(),
relay_url: None,
public_key: None,
};
tags.push(Tag::from_standardized_without_cell(tag))
}
}
// Construct a direct message event
//
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
.tags(tags)
.build(public_key);
// Ensure the event id has been generated
event.ensure_id();
event
}
/// Create a task to send a message to all room members
pub fn send_message(
&self,
rumor: &UnsignedEvent,
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get current user's public key and relays
let current_user = nostr.read(cx).identity().read(cx).public_key();
let current_user_relays = nostr.read(cx).messaging_relays(&current_user, cx);
let rumor = rumor.to_owned();
// Get all members and their messaging relays
let task = self.members_with_relays(cx);
cx.background_spawn(async move {
let signer = client.signer().await?;
let current_user_relays = current_user_relays.await;
let mut members = task.await;
// Remove the current user's public key from the list of receivers
// the current user will be handled separately
members.retain(|(this, _)| this != &current_user);
// Collect the send reports
let mut reports: Vec<SendReport> = vec![];
for (receiver, relays) in members.into_iter() {
// Check if there are any relays to send the message to
if relays.is_empty() {
reports.push(SendReport::new(receiver).relays_not_found());
continue;
}
// Ensure relay connection
for url in relays.iter() {
client.add_relay(url).await?;
client.connect_relay(url).await?;
}
// Construct the gift wrap event
let event =
EventBuilder::gift_wrap(&signer, &receiver, rumor.clone(), vec![]).await?;
// Send the gift wrap event to the messaging relays
match client.send_event_to(relays, &event).await {
Ok(output) => {
let id = output.id().to_owned();
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
let report = SendReport::new(receiver).status(output);
let tracker = tracker().read().await;
if auth {
// Wait for authenticated and resent event successfully
for attempt in 0..=SEND_RETRY {
// Check if event was successfully resent
if tracker.is_sent_by_coop(&id) {
let output = Output::new(id);
let report = SendReport::new(receiver).status(output);
reports.push(report);
break;
}
// Check if retry limit exceeded
if attempt == SEND_RETRY {
reports.push(report);
break;
}
smol::Timer::after(Duration::from_millis(1200)).await;
}
} else {
reports.push(report);
}
}
Err(e) => {
reports.push(SendReport::new(receiver).error(e.to_string()));
}
}
}
// Construct the gift-wrapped event
let event =
EventBuilder::gift_wrap(&signer, &current_user, rumor.clone(), vec![]).await?;
// Only send a backup message to current user if sent successfully to others
if reports.iter().all(|r| r.is_sent_success()) {
// Check if there are any relays to send the event to
if current_user_relays.is_empty() {
reports.push(SendReport::new(current_user).relays_not_found());
return Ok(reports);
}
// Ensure relay connection
for url in current_user_relays.iter() {
client.add_relay(url).await?;
client.connect_relay(url).await?;
}
// Send the event to the messaging relays
match client.send_event_to(current_user_relays, &event).await {
Ok(output) => {
reports.push(SendReport::new(current_user).status(output));
}
Err(e) => {
reports.push(SendReport::new(current_user).error(e.to_string()));
}
}
} else {
reports.push(SendReport::new(current_user).on_hold(event));
}
Ok(reports)
})
}
/// Create a task to resend a failed message
pub fn resend_message(
&self,
reports: Vec<SendReport>,
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let mut resend_reports = vec![];
for report in reports.into_iter() {
let receiver = report.receiver;
// Process failed events
if let Some(output) = report.status {
let id = output.id();
let urls: Vec<&RelayUrl> = output.failed.keys().collect();
if let Some(event) = client.database().event_by_id(id).await? {
for url in urls.into_iter() {
let relay = client.pool().relay(url).await?;
let id = relay.send_event(&event).await?;
let resent: Output<EventId> = Output {
val: id,
success: HashSet::from([url.to_owned()]),
failed: HashMap::new(),
};
resend_reports.push(SendReport::new(receiver).status(resent));
}
}
}
// Process the on hold event if it exists
if let Some(event) = report.on_hold {
// Send the event to the messaging relays
match client.send_event(&event).await {
Ok(output) => {
resend_reports.push(SendReport::new(receiver).status(output));
}
Err(e) => {
resend_reports.push(SendReport::new(receiver).error(e.to_string()));
}
}
}
}
Ok(resend_reports)
})
}
}

31
crates/chat_ui/Cargo.toml Normal file
View File

@@ -0,0 +1,31 @@
[package]
name = "chat_ui"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
state = { path = "../state" }
ui = { path = "../ui" }
theme = { path = "../theme" }
common = { path = "../common" }
person = { path = "../person" }
chat = { path = "../chat" }
settings = { path = "../settings" }
gpui.workspace = true
gpui_tokio.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true
indexset = "0.12.3"
emojis = "0.6.4"
once_cell = "1.19.0"
regex = "1"

View File

@@ -0,0 +1,17 @@
use gpui::Action;
use nostr_sdk::prelude::*;
use serde::Deserialize;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = chat, no_json)]
pub struct SeenOn(pub EventId);
/// Define a open public key action
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
#[action(namespace = pubkey, no_json)]
pub struct OpenPublicKey(pub PublicKey);
/// Define a copy inline public key action
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
#[action(namespace = pubkey, no_json)]
pub struct CopyPublicKey(pub PublicKey);

View File

@@ -6,11 +6,10 @@ use gpui::{
RenderOnce, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window,
};
use theme::ActiveTheme;
use crate::button::{Button, ButtonVariants};
use crate::input::InputState;
use crate::popover::{Popover, PopoverContent};
use crate::{Icon, Sizable, Size};
use ui::button::{Button, ButtonVariants};
use ui::input::InputState;
use ui::popover::{Popover, PopoverContent};
use ui::{Icon, Sizable, Size};
static EMOJIS: OnceLock<Vec<SharedString>> = OnceLock::new();
@@ -31,27 +30,33 @@ fn get_emojis() -> &'static Vec<SharedString> {
#[derive(IntoElement)]
pub struct EmojiPicker {
target: Option<WeakEntity<InputState>>,
icon: Option<Icon>,
size: Size,
anchor: Option<Corner>,
target_input: WeakEntity<InputState>,
size: Size,
}
impl EmojiPicker {
pub fn new(target_input: WeakEntity<InputState>) -> Self {
pub fn new() -> Self {
Self {
target_input,
size: Size::default(),
target: None,
anchor: None,
icon: None,
}
}
pub fn target(mut self, target: WeakEntity<InputState>) -> Self {
self.target = Some(target);
self
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
#[allow(dead_code)]
pub fn anchor(mut self, corner: Corner) -> Self {
self.anchor = Some(corner);
self
@@ -67,7 +72,7 @@ impl Sizable for EmojiPicker {
impl RenderOnce for EmojiPicker {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
Popover::new("emoji-picker")
Popover::new("emojis")
.map(|this| {
if let Some(corner) = self.anchor {
this.anchor(corner)
@@ -76,13 +81,13 @@ impl RenderOnce for EmojiPicker {
}
})
.trigger(
Button::new("emoji-trigger")
Button::new("emojis-trigger")
.when_some(self.icon, |this, icon| this.icon(icon))
.ghost()
.with_size(self.size),
)
.content(move |window, cx| {
let input = self.target_input.clone();
let input = self.target.clone();
cx.new(|cx| {
PopoverContent::new(window, cx, move |_window, cx| {
@@ -104,18 +109,18 @@ impl RenderOnce for EmojiPicker {
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.on_click({
let item = e.clone();
let input = input.upgrade();
let input = input.clone();
move |_, window, cx| {
if let Some(input) = input.as_ref() {
input.update(cx, |this, cx| {
let current = this.value();
let new_text = if current.is_empty() {
_ = input.update(cx, |this, cx| {
let value = this.value();
let new_text = if value.is_empty() {
format!("{item}")
} else if current.ends_with(" ") {
format!("{current}{item}")
} else if value.ends_with(" ") {
format!("{value}{item}")
} else {
format!("{current} {item}")
format!("{value} {item}")
};
this.set_value(new_text, window, cx);
});

1210
crates/chat_ui/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
use gpui::{
div, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Window,
};
use theme::ActiveTheme;
use ui::input::{InputState, TextInput};
use ui::{v_flex, Sizable};
pub fn init(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Subject> {
cx.new(|cx| Subject::new(subject, window, cx))
}
pub struct Subject {
input: Entity<InputState>,
}
impl Subject {
pub fn new(subject: Option<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Plan for holiday"));
if let Some(value) = subject {
input.update(cx, |this, cx| {
this.set_value(value, window, cx);
});
};
Self { input }
}
pub fn new_subject(&self, cx: &App) -> SharedString {
self.input.read(cx).value()
}
}
impl Render for Subject {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_2()
.child(
v_flex()
.gap_1()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Subject:")),
)
.child(TextInput::new(&self.input).small()),
)
.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(SharedString::from(
"Subject will be updated when you send a new message.",
)),
)
}
}

View File

@@ -1,22 +1,20 @@
use std::ops::Range;
use std::sync::Arc;
use common::display::DisplayProfile;
use gpui::{
AnyElement, AnyView, App, ElementId, HighlightStyle, InteractiveText, IntoElement,
SharedString, StyledText, UnderlineStyle, Window,
AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString,
StyledText, UnderlineStyle, Window,
};
use linkify::{LinkFinder, LinkKind};
use nostr_sdk::prelude::*;
use once_cell::sync::Lazy;
use person::PersonRegistry;
use regex::Regex;
use registry::Registry;
use theme::ActiveTheme;
use crate::actions::OpenProfile;
use crate::actions::OpenPublicKey;
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^(?:[a-zA-Z]+://)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(/.*)?$").unwrap()
Regex::new(r"(?i)(?:^|\s)(?:https?://)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:/[^\s]*)?(?:\s|$)").unwrap()
});
static NOSTR_URI_REGEX: Lazy<Regex> =
@@ -24,46 +22,19 @@ static NOSTR_URI_REGEX: Lazy<Regex> =
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
Link(HighlightStyle),
Link,
Nostr,
}
impl Highlight {
fn link() -> Self {
Self::Link(HighlightStyle {
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
})
}
fn nostr() -> Self {
Self::Nostr
}
}
impl From<HighlightStyle> for Highlight {
fn from(style: HighlightStyle) -> Self {
Self::Link(style)
}
}
type CustomRangeTooltipFn =
Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>;
#[derive(Default)]
pub struct RichText {
pub struct RenderedText {
pub text: SharedString,
pub highlights: Vec<(Range<usize>, Highlight)>,
pub link_ranges: Vec<Range<usize>>,
pub link_urls: Arc<[String]>,
pub custom_ranges: Vec<Range<usize>>,
custom_ranges_tooltip_fn: CustomRangeTooltipFn,
}
impl RichText {
impl RenderedText {
pub fn new(content: &str, cx: &App) -> Self {
let mut text = String::new();
let mut highlights = Vec::new();
@@ -81,24 +52,15 @@ impl RichText {
text.truncate(text.trim_end().len());
RichText {
RenderedText {
text: SharedString::from(text),
link_urls: link_urls.into(),
link_ranges,
highlights,
custom_ranges: Vec::new(),
custom_ranges_tooltip_fn: None,
}
}
pub fn set_tooltip_builder_for_custom_ranges<F>(&mut self, f: F)
where
F: Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView> + 'static,
{
self.custom_ranges_tooltip_fn = Some(Arc::new(f));
}
pub fn element(&self, id: ElementId, window: &mut Window, cx: &App) -> AnyElement {
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let link_color = cx.theme().text_accent;
InteractiveText::new(
@@ -109,17 +71,11 @@ impl RichText {
(
range.clone(),
match highlight {
Highlight::Link(highlight) => {
// Check if this is a link highlight by seeing if it has an underline
if highlight.underline.is_some() {
// It's a link, so apply the link color
let mut link_style = *highlight;
link_style.color = Some(link_color);
link_style
} else {
*highlight
}
}
Highlight::Link => HighlightStyle {
color: Some(link_color),
underline: Some(UnderlineStyle::default()),
..Default::default()
},
Highlight::Nostr => HighlightStyle {
color: Some(link_color),
..Default::default()
@@ -134,49 +90,22 @@ impl RichText {
move |ix, window, cx| {
let token = link_urls[ix].as_str();
if token.starts_with("nostr:") {
let clean_url = token.replace("nostr:", "");
let Ok(public_key) = PublicKey::parse(&clean_url) else {
log::error!("Failed to parse public key from: {clean_url}");
return;
};
window.dispatch_action(Box::new(OpenProfile(public_key)), cx);
} else if is_url(token) {
if !token.starts_with("http") {
cx.open_url(&format!("https://{token}"));
} else {
cx.open_url(token);
if let Some(clean_url) = token.strip_prefix("nostr:") {
if let Ok(public_key) = PublicKey::parse(clean_url) {
window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
}
} else if is_url(token) {
let url = if token.starts_with("http") {
token.to_string()
} else {
format!("https://{token}")
};
cx.open_url(&url);
} else {
log::warn!("Unrecognized token {token}")
}
}
})
.tooltip({
let link_ranges = self.link_ranges.clone();
let link_urls = self.link_urls.clone();
let custom_tooltip_ranges = self.custom_ranges.clone();
let custom_tooltip_fn = self.custom_ranges_tooltip_fn.clone();
move |idx, window, cx| {
for (ix, range) in link_ranges.iter().enumerate() {
if range.contains(&idx) {
let url = &link_urls[ix];
if url.starts_with("http") {
// return Some(LinkPreview::new(url, cx));
}
// You can add custom tooltip handling for mentions here
}
}
for range in &custom_tooltip_ranges {
if range.contains(&idx) {
if let Some(f) = &custom_tooltip_fn {
return f(idx, range.clone(), window, cx);
}
}
}
None
}
})
.into_any_element()
}
}
@@ -192,18 +121,11 @@ fn render_plain_text_mut(
// Copy the content directly
text.push_str(content);
// Initialize the link finder
let mut finder = LinkFinder::new();
finder.url_must_have_scheme(false);
finder.kinds(&[LinkKind::Url]);
// Collect all URLs
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
for link in finder.links(content) {
let start = link.start();
let end = link.end();
let range = start..end;
for link in URL_REGEX.find_iter(content) {
let range = link.start()..link.end();
let url = link.as_str().to_string();
url_matches.push((range, url));
@@ -213,9 +135,7 @@ fn render_plain_text_mut(
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
let start = nostr_match.start();
let end = nostr_match.end();
let range = start..end;
let range = nostr_match.start()..nostr_match.end();
let nostr_uri = nostr_match.as_str().to_string();
// Check if this nostr URI overlaps with any already processed URL
@@ -239,12 +159,9 @@ fn render_plain_text_mut(
for (range, entity) in all_matches {
// Handle URL token
if is_url(&entity) {
// Add underline highlight
highlights.push((range.clone(), Highlight::link()));
// Make it clickable
highlights.push((range.clone(), Highlight::Link));
link_ranges.push(range);
link_urls.push(entity);
continue;
};
@@ -305,75 +222,6 @@ fn render_plain_text_mut(
}
}
}
fn render_pubkey(
public_key: PublicKey,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
cx: &App,
) {
let registry = Registry::read_global(cx);
let profile = registry.get_person(&public_key, cx);
let display_name = format!("@{}", profile.display_name());
// Replace token with display name
text.replace_range(range.clone(), &display_name);
// Adjust ranges
let new_length = display_name.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement
let new_range = range.start..(range.start + new_length);
// Add highlight for the profile name
highlights.push((new_range.clone(), Highlight::nostr()));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
// Adjust subsequent ranges if needed
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
fn render_bech32(
bech32: String,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
) {
let njump_url = format!("https://njump.me/{bech32}");
// Create a shortened display format for the URL
let shortened_entity = format_shortened_entity(&bech32);
let display_text = format!("https://njump.me/{shortened_entity}");
// Replace the original entity with the shortened display version
text.replace_range(range.clone(), &display_text);
// Adjust the ranges
let new_length = display_text.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement
let new_range = range.start..(range.start + new_length);
// Add underline highlight
highlights.push((new_range.clone(), Highlight::link()));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(njump_url);
// Adjust subsequent ranges if needed
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
}
/// Check if a string is a URL
@@ -395,6 +243,61 @@ fn format_shortened_entity(entity: &str) -> String {
}
}
fn render_pubkey(
public_key: PublicKey,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
cx: &App,
) {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
let display_name = format!("@{}", profile.name());
text.replace_range(range.clone(), &display_name);
let new_length = display_name.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
let new_range = range.start..(range.start + new_length);
highlights.push((new_range.clone(), Highlight::Nostr));
link_ranges.push(new_range);
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
fn render_bech32(
bech32: String,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
) {
let njump_url = format!("https://njump.me/{bech32}");
let shortened_entity = format_shortened_entity(&bech32);
let display_text = format!("https://njump.me/{shortened_entity}");
text.replace_range(range.clone(), &display_text);
let new_length = display_text.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
let new_range = range.start..(range.start + new_length);
highlights.push((new_range.clone(), Highlight::Link));
link_ranges.push(new_range);
link_urls.push(njump_url);
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
// Helper function to adjust ranges when text length changes
fn adjust_ranges(
highlights: &mut [(Range<usize>, Highlight)],

View File

@@ -1,129 +0,0 @@
use global::{constants::KEYRING_URL, first_run};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
pub fn init(cx: &mut App) {
ClientKeys::set_global(cx.new(ClientKeys::new), cx);
}
struct GlobalClientKeys(Entity<ClientKeys>);
impl Global for GlobalClientKeys {}
pub struct ClientKeys {
keys: Option<Keys>,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl ClientKeys {
/// Retrieve the Global Client Keys instance
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalClientKeys>().0.clone()
}
/// Retrieve the Client Keys instance
pub fn get_global(cx: &App) -> &Self {
cx.global::<GlobalClientKeys>().0.read(cx)
}
/// Set the Global Client Keys instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalClientKeys(state));
}
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
if let Some(window) = window {
this.load(window, cx);
}
}));
Self {
keys: None,
subscriptions,
}
}
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let read_client_keys = cx.read_credentials(KEYRING_URL);
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some((_, secret))) = read_client_keys.await {
// Update keys
this.update(cx, |this, cx| {
let Ok(secret_key) = SecretKey::from_slice(&secret) else {
this.set_keys(None, false, true, cx);
return;
};
let keys = Keys::new(secret_key);
this.set_keys(Some(keys), false, true, cx);
})
.ok();
} else if *first_run() {
// Generate a new keys and update
this.update(cx, |this, cx| {
this.new_keys(cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_keys(None, false, true, cx);
})
.ok();
}
})
.detach();
}
pub(crate) fn set_keys(
&mut self,
keys: Option<Keys>,
persist: bool,
notify: bool,
cx: &mut Context<Self>,
) {
if persist {
if let Some(keys) = keys.as_ref() {
let username = keys.public_key().to_hex();
let password = keys.secret_key().secret_bytes();
let write_keys = cx.write_credentials(KEYRING_URL, &username, &password);
cx.background_spawn(async move {
if let Err(e) = write_keys.await {
log::error!("Failed to save the client keys: {e}")
}
})
.detach();
}
}
self.keys = keys;
// Notify GPUI to reload UI
if notify {
cx.notify();
}
}
pub fn new_keys(&mut self, cx: &mut Context<Self>) {
self.set_keys(Some(Keys::generate()), true, true, cx);
}
pub fn force_new_keys(&mut self, cx: &mut Context<Self>) {
self.set_keys(Some(Keys::generate()), true, false, cx);
}
pub fn keys(&self) -> Keys {
self.keys
.as_ref()
.cloned()
.expect("Keys should always be initialized")
}
pub fn has_keys(&self) -> bool {
self.keys.is_some()
}
}

View File

@@ -5,12 +5,9 @@ edition.workspace = true
publish.workspace = true
[dependencies]
global = { path = "../global" }
gpui.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
nostr.workspace = true
anyhow.workspace = true
itertools.workspace = true
chrono.workspace = true
@@ -20,5 +17,7 @@ futures.workspace = true
reqwest.workspace = true
log.workspace = true
webbrowser = "1.0.4"
qrcode-generator = "5.0.0"
dirs = "5.0"
qrcode = "0.14.1"
whoami = "1.6.1"
nostr = { git = "https://github.com/rust-nostr/nostr" }

View File

@@ -0,0 +1,31 @@
pub const CLIENT_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop";
/// Bootstrap Relays.
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nos.social",
"wss://user.kindpag.es",
];
/// Search Relays.
pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.noswhere.com"];
/// Default relay for Nostr Connect
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
/// Default retry count for fetching NIP-17 relays
pub const RELAY_RETRY: u64 = 2;
/// Default retry count for sending messages
pub const SEND_RETRY: u64 = 10;
/// Default timeout (in seconds) for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Default timeout (in seconds) for Nostr Connect (Bunker)
pub const BUNKER_TIMEOUT: u64 = 30;
/// Default width of the sidebar.
pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.;

View File

@@ -1,30 +1,38 @@
use std::sync::Arc;
use anyhow::{anyhow, Error};
use global::constants::IMAGE_RESIZE_SERVICE;
use chrono::{Local, TimeZone};
use gpui::{Image, ImageFormat, SharedString};
use nostr_sdk::prelude::*;
use qrcode_generator::QrCodeEcc;
use qrcode::render::svg;
use qrcode::QrCode;
const NOW: &str = "now";
const SECONDS_IN_MINUTE: i64 = 60;
const MINUTES_IN_HOUR: i64 = 60;
const HOURS_IN_DAY: i64 = 24;
const DAYS_IN_MONTH: i64 = 30;
const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png";
const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl";
pub trait DisplayProfile {
fn avatar_url(&self, proxy: bool) -> SharedString;
pub trait RenderedProfile {
fn avatar(&self, proxy: bool) -> SharedString;
fn display_name(&self) -> SharedString;
}
impl DisplayProfile for Profile {
fn avatar_url(&self, proxy: bool) -> SharedString {
impl RenderedProfile for Profile {
fn avatar(&self, proxy: bool) -> SharedString {
self.metadata()
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| {
if proxy {
format!(
let url = format!(
"{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1"
)
.into()
);
url.into()
} else {
picture.into()
}
@@ -35,17 +43,65 @@ impl DisplayProfile for Profile {
fn display_name(&self) -> SharedString {
if let Some(display_name) = self.metadata().display_name.as_ref() {
if !display_name.is_empty() {
return display_name.into();
return SharedString::from(display_name);
}
}
if let Some(name) = self.metadata().name.as_ref() {
if !name.is_empty() {
return name.into();
return SharedString::from(name);
}
}
shorten_pubkey(self.public_key(), 4)
SharedString::from(shorten_pubkey(self.public_key(), 4))
}
}
pub trait RenderedTimestamp {
fn to_human_time(&self) -> SharedString;
fn to_ago(&self) -> SharedString;
}
impl RenderedTimestamp for Timestamp {
fn to_human_time(&self) -> SharedString {
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return SharedString::from("9999"),
};
let now = Local::now();
let input_date = input_time.date_naive();
let now_date = now.date_naive();
let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
let time_format = input_time.format("%H:%M %p");
match input_date {
date if date == now_date => SharedString::from(format!("Today at {time_format}")),
date if date == yesterday_date => {
SharedString::from(format!("Yesterday at {time_format}"))
}
_ => SharedString::from(format!("{}, {time_format}", input_time.format("%d/%m/%y"))),
}
}
fn to_ago(&self) -> SharedString {
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return SharedString::from("1m"),
};
let now = Local::now();
let duration = now.signed_duration_since(input_time);
match duration {
d if d.num_seconds() < SECONDS_IN_MINUTE => SharedString::from(NOW),
d if d.num_minutes() < MINUTES_IN_HOUR => {
SharedString::from(format!("{}m", d.num_minutes()))
}
d if d.num_hours() < HOURS_IN_DAY => SharedString::from(format!("{}h", d.num_hours())),
d if d.num_days() < DAYS_IN_MONTH => SharedString::from(format!("{}d", d.num_days())),
_ => SharedString::from(input_time.format("%b %d").to_string()),
}
}
}
@@ -54,49 +110,36 @@ pub trait TextUtils {
fn to_qr(&self) -> Option<Arc<Image>>;
}
impl TextUtils for String {
impl<T: AsRef<str>> TextUtils for T {
fn to_public_key(&self) -> Result<PublicKey, Error> {
if self.starts_with("nprofile1") {
Ok(Nip19Profile::from_bech32(self)?.public_key)
} else if self.starts_with("npub1") {
Ok(PublicKey::parse(self)?)
let s = self.as_ref();
if s.starts_with("nprofile1") {
Ok(Nip19Profile::from_bech32(s)?.public_key)
} else if s.starts_with("npub1") {
Ok(PublicKey::parse(s)?)
} else {
Err(anyhow!("Invalid public key"))
}
}
fn to_qr(&self) -> Option<Arc<Image>> {
let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(self, QrCodeEcc::Medium, 256)
else {
return None;
};
let s = self.as_ref();
let code = QrCode::new(s).unwrap();
let svg = code
.render()
.min_dimensions(256, 256)
.dark_color(svg::Color("#000000"))
.light_color(svg::Color("#FFFFFF"))
.build();
Some(Arc::new(Image::from_bytes(ImageFormat::Png, bytes)))
Some(Arc::new(Image::from_bytes(
ImageFormat::Svg,
svg.into_bytes(),
)))
}
}
impl TextUtils for &str {
fn to_public_key(&self) -> Result<PublicKey, Error> {
if self.starts_with("nprofile1") {
Ok(Nip19Profile::from_bech32(self)?.public_key)
} else if self.starts_with("npub1") {
Ok(PublicKey::parse(self)?)
} else {
Err(anyhow!("Invalid public key"))
}
}
fn to_qr(&self) -> Option<Arc<Image>> {
let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(self, QrCodeEcc::Medium, 256)
else {
return None;
};
Some(Arc::new(Image::from_bytes(ImageFormat::Png, bytes)))
}
}
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> SharedString {
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
let Ok(pubkey) = public_key.to_bech32();
format!(
@@ -104,5 +147,4 @@ pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> SharedString {
&pubkey[0..(len + 1)],
&pubkey[pubkey.len() - len..]
)
.into()
}

View File

@@ -1,4 +1,3 @@
use std::collections::HashSet;
use std::hash::{DefaultHasher, Hash, Hasher};
use itertools::Itertools;
@@ -6,11 +5,27 @@ use nostr_sdk::prelude::*;
pub trait EventUtils {
fn uniq_id(&self) -> u64;
fn all_pubkeys(&self) -> Vec<PublicKey>;
fn compare_pubkeys(&self, other: &[PublicKey]) -> bool;
fn extract_public_keys(&self) -> Vec<PublicKey>;
}
impl EventUtils for Event {
fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = self.extract_public_keys();
pubkeys.sort();
pubkeys.hash(&mut hasher);
hasher.finish()
}
fn extract_public_keys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
public_keys.push(self.pubkey);
public_keys.into_iter().unique().collect()
}
}
impl EventUtils for UnsignedEvent {
fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = vec![];
@@ -30,18 +45,9 @@ impl EventUtils for Event {
hasher.finish()
}
fn all_pubkeys(&self) -> Vec<PublicKey> {
fn extract_public_keys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
public_keys.push(self.pubkey);
public_keys
}
fn compare_pubkeys(&self, other: &[PublicKey]) -> bool {
let pubkeys = self.all_pubkeys();
let a: HashSet<_> = pubkeys.iter().collect();
let b: HashSet<_> = other.iter().collect();
a == b
public_keys.into_iter().unique().sorted().collect()
}
}

View File

@@ -1,14 +0,0 @@
use nostr_connect::prelude::*;
#[derive(Debug, Clone)]
pub struct CoopAuthUrlHandler;
impl AuthUrlHandler for CoopAuthUrlHandler {
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
Box::pin(async move {
log::info!("Received Auth URL: {auth_url}");
webbrowser::open(auth_url.as_str())?;
Ok(())
})
}
}

View File

@@ -1,6 +1,68 @@
pub mod debounced_delay;
pub mod display;
pub mod event;
pub mod handle_auth;
pub mod nip05;
pub mod nip96;
use std::sync::OnceLock;
pub use constants::*;
pub use debounced_delay::*;
pub use display::*;
pub use event::*;
pub use nip05::*;
pub use nip96::*;
use nostr_sdk::prelude::*;
pub use paths::*;
mod constants;
mod debounced_delay;
mod display;
mod event;
mod nip05;
mod nip96;
mod paths;
static APP_NAME: OnceLock<String> = OnceLock::new();
static NIP65_RELAYS: OnceLock<Vec<(RelayUrl, Option<RelayMetadata>)>> = OnceLock::new();
static NIP17_RELAYS: OnceLock<Vec<RelayUrl>> = OnceLock::new();
/// Get the app name
pub fn app_name() -> &'static String {
APP_NAME.get_or_init(|| {
let devicename = whoami::devicename();
let platform = whoami::platform();
format!("{CLIENT_NAME} on {platform} ({devicename})")
})
}
/// Default NIP-65 Relays. Used for new account
pub fn default_nip65_relays() -> &'static Vec<(RelayUrl, Option<RelayMetadata>)> {
NIP65_RELAYS.get_or_init(|| {
vec![
(
RelayUrl::parse("wss://nostr.mom").unwrap(),
Some(RelayMetadata::Read),
),
(
RelayUrl::parse("wss://nostr.bitcoiner.social").unwrap(),
Some(RelayMetadata::Read),
),
(
RelayUrl::parse("wss://nos.lol").unwrap(),
Some(RelayMetadata::Write),
),
(
RelayUrl::parse("wss://relay.snort.social").unwrap(),
Some(RelayMetadata::Write),
),
(RelayUrl::parse("wss://relay.primal.net").unwrap(), None),
(RelayUrl::parse("wss://relay.damus.io").unwrap(), None),
]
})
}
/// Default NIP-17 Relays. Used for new account
pub fn default_nip17_relays() -> &'static Vec<RelayUrl> {
NIP17_RELAYS.get_or_init(|| {
vec![
RelayUrl::parse("wss://nip17.com").unwrap(),
RelayUrl::parse("wss://auth.nostr1.com").unwrap(),
]
})
}

View File

@@ -14,7 +14,7 @@ product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop"
category = "SocialNetworking"
version = "0.2.2"
version = "0.3.0"
out-dir = "../../dist"
before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"]
@@ -30,34 +30,35 @@ icons = [
assets = { path = "../assets" }
ui = { path = "../ui" }
title_bar = { path = "../title_bar" }
identity = { path = "../identity" }
theme = { path = "../theme" }
common = { path = "../common" }
global = { path = "../global" }
registry = { path = "../registry" }
state = { path = "../state" }
device = { path = "../device" }
key_store = { path = "../key_store" }
chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
settings = { path = "../settings" }
client_keys = { path = "../client_keys" }
auto_update = { path = "../auto_update" }
person = { path = "../person" }
relay_auth = { path = "../relay_auth" }
rust-i18n.workspace = true
i18n.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
reqwest_client.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
nostr.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
itertools.workspace = true
dirs.workspace = true
log.workspace = true
smallvec.workspace = true
smol.workspace = true
futures.workspace = true
oneshot.workspace = true
webbrowser.workspace = true
indexset = "0.12.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }

View File

@@ -0,0 +1,94 @@
use std::sync::Mutex;
use gpui::{actions, App};
use key_store::{KeyItem, KeyStore};
use nostr_connect::prelude::*;
use state::NostrRegistry;
// Sidebar actions
actions!(sidebar, [Reload, RelayStatus]);
// User actions
actions!(
coop,
[
KeyringPopup,
DarkMode,
ViewProfile,
ViewRelays,
Themes,
Settings,
Logout,
Quit
]
);
#[derive(Debug, Clone)]
pub struct CoopAuthUrlHandler;
impl AuthUrlHandler for CoopAuthUrlHandler {
#[allow(mismatched_lifetime_syntaxes)]
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
Box::pin(async move {
log::info!("Received Auth URL: {auth_url}");
webbrowser::open(auth_url.as_str())?;
Ok(())
})
}
}
pub fn load_embedded_fonts(cx: &App) {
let asset_source = cx.asset_source();
let font_paths = asset_source.list("fonts").unwrap();
let embedded_fonts = Mutex::new(Vec::new());
let executor = cx.background_executor();
executor.block(executor.scoped(|scope| {
for font_path in &font_paths {
if !font_path.ends_with(".ttf") {
continue;
}
scope.spawn(async {
let font_bytes = asset_source.load(font_path).unwrap().unwrap();
embedded_fonts.lock().unwrap().push(font_bytes);
});
}
}));
cx.text_system()
.add_fonts(embedded_fonts.into_inner().unwrap())
.unwrap();
}
pub fn reset(cx: &mut App) {
let backend = KeyStore::global(cx).read(cx).backend();
let client = NostrRegistry::global(cx).read(cx).client();
cx.spawn(async move |cx| {
// Remove the signer
client.unset_signer().await;
// Delete user's credentials
backend
.delete_credentials(&KeyItem::User.to_string(), cx)
.await
.ok();
// Remove bunker's credentials if available
backend
.delete_credentials(&KeyItem::Bunker.to_string(), cx)
.await
.ok();
cx.update(|cx| {
cx.restart();
});
})
.detach();
}
pub fn quit(_: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,427 @@
use std::time::Duration;
use anyhow::anyhow;
use common::BUNKER_TIMEOUT;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use key_store::{KeyItem, KeyStore};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{v_flex, ContextModal, Disableable, StyledExt};
use crate::actions::CoopAuthUrlHandler;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
cx.new(|cx| Login::new(window, cx))
}
#[derive(Debug)]
pub struct Login {
key_input: Entity<InputState>,
pass_input: Entity<InputState>,
error: Entity<Option<SharedString>>,
countdown: Entity<Option<u64>>,
require_password: bool,
logging_in: bool,
/// Panel
name: SharedString,
focus_handle: FocusHandle,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl Login {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let key_input = cx.new(|cx| InputState::new(window, cx));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
let countdown = cx.new(|_| None);
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to key input events and process login when the user presses enter
cx.subscribe_in(&key_input, window, |this, input, event, window, cx| {
match event {
InputEvent::PressEnter { .. } => {
this.login(window, cx);
}
InputEvent::Change => {
if input.read(cx).value().starts_with("ncryptsec1") {
this.require_password = true;
cx.notify();
}
}
_ => {}
};
}),
);
Self {
key_input,
pass_input,
error,
countdown,
name: "Welcome Back".into(),
focus_handle: cx.focus_handle(),
logging_in: false,
require_password: false,
_subscriptions: subscriptions,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.logging_in {
return;
};
// Prevent duplicate login requests
self.set_logging_in(true, cx);
let value = self.key_input.read(cx).value();
let password = self.pass_input.read(cx).value();
if value.starts_with("bunker://") {
self.login_with_bunker(&value, window, cx);
} else if value.starts_with("ncryptsec1") {
self.login_with_password(&value, &password, cx);
} else if value.starts_with("nsec1") {
if let Ok(secret) = SecretKey::parse(&value) {
let keys = Keys::new(secret);
self.login_with_keys(keys, cx);
} else {
self.set_error("Invalid", cx);
}
} else {
self.set_error("Invalid", cx);
}
}
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectUri::parse(content) else {
self.set_error("Bunker is not valid", cx);
return;
};
let app_keys = Keys::generate();
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Start countdown
cx.spawn_in(window, async move |this, cx| {
for i in (0..=BUNKER_TIMEOUT).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})
.ok();
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
})
.detach();
// Handle connection
cx.spawn_in(window, async move |this, cx| {
let result = signer.bunker_uri().await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(uri) => {
this.save_connection(&app_keys, &uri, window, cx);
this.connect(signer, cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
})
.detach();
}
fn save_connection(
&mut self,
keys: &Keys,
uri: &NostrConnectUri,
window: &mut Window,
cx: &mut Context<Self>,
) {
let keystore = KeyStore::global(cx).read(cx).backend();
let username = keys.public_key().to_hex();
let secret = keys.secret_key().to_secret_bytes();
let mut clean_uri = uri.to_string();
// Clear the secret parameter in the URI if it exists
if let Some(s) = uri.secret() {
clean_uri = clean_uri.replace(s, "");
}
cx.spawn_in(window, async move |this, cx| {
let user_url = KeyItem::User.to_string();
let bunker_url = KeyItem::Bunker.to_string();
let user_password = clean_uri.into_bytes();
// Write bunker uri to keyring for further connection
if let Err(e) = keystore
.write_credentials(&user_url, "bunker", &user_password, cx)
.await
{
this.update_in(cx, |_, window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
// Write the app keys for further connection
if let Err(e) = keystore
.write_credentials(&bunker_url, &username, &secret, cx)
.await
{
this.update_in(cx, |_, window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
})
.detach();
}
fn connect(&mut self, signer: NostrConnect, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.set_signer(signer, cx);
});
}
pub fn login_with_password(&mut self, content: &str, pwd: &str, cx: &mut Context<Self>) {
if pwd.is_empty() {
self.set_error("Password is required", cx);
return;
}
let Ok(enc) = EncryptedSecretKey::from_bech32(content) else {
self.set_error("Secret Key is invalid", cx);
return;
};
let password = pwd.to_owned();
// Decrypt in the background to ensure it doesn't block the UI
let task = cx.background_spawn(async move {
if let Ok(content) = enc.decrypt(&password) {
Ok(Keys::new(content))
} else {
Err(anyhow!("Invalid password"))
}
});
cx.spawn(async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| {
match result {
Ok(keys) => {
this.login_with_keys(keys, cx);
}
Err(e) => {
this.set_error(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
pub fn login_with_keys(&mut self, keys: Keys, cx: &mut Context<Self>) {
let keystore = KeyStore::global(cx).read(cx).backend();
let username = keys.public_key().to_hex();
let secret = keys.secret_key().to_secret_hex().into_bytes();
cx.spawn(async move |this, cx| {
let bunker_url = KeyItem::User.to_string();
// Write the app keys for further connection
if let Err(e) = keystore
.write_credentials(&bunker_url, &username, &secret, cx)
.await
{
this.update(cx, |this, cx| {
this.set_error(e.to_string(), cx);
})
.ok();
}
this.update(cx, |_this, cx| {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.set_signer(keys, cx);
});
})
.ok();
})
.detach();
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
// Reset the log in state
self.set_logging_in(false, cx);
// Reset the countdown
self.set_countdown(None, cx);
// Update error message
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
// Clear the error message after 3 secs
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(3)).await;
this.update(cx, |this, cx| {
this.error.update(cx, |this, cx| {
*this = None;
cx.notify();
});
})
.ok();
})
.detach();
}
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.logging_in = status;
cx.notify();
}
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
self.countdown.update(cx, |this, cx| {
*this = i;
cx.notify();
});
}
}
impl Panel for Login {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for Login {}
impl Focusable for Login {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Login {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.relative()
.size_full()
.items_center()
.justify_center()
.child(
v_flex()
.w_96()
.gap_10()
.child(
div()
.text_center()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::from("Continue with Private Key or Bunker")),
)
.child(
v_flex()
.gap_3()
.text_sm()
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("nsec or bunker://")
.child(TextInput::new(&self.key_input)),
)
.when(self.require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("Password:")
.child(TextInput::new(&self.pass_input)),
)
})
.child(
Button::new("login")
.label("Continue")
.primary()
.loading(self.logging_in)
.disabled(self.logging_in)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(SharedString::from(format!(
"Approve connection request from your signer in {} seconds",
i
))),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
),
)
}
}

View File

@@ -1,176 +1,33 @@
use std::collections::BTreeSet;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::sync::Arc;
use anyhow::{anyhow, Error};
use assets::Assets;
use common::event::EventUtils;
use global::constants::{
APP_ID, APP_NAME, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT, METADATA_BATCH_TIMEOUT,
SEARCH_RELAYS, WAIT_FOR_FINISH,
};
use global::{nostr_client, processed_events, starting_time, NostrSignal};
use common::{APP_ID, CLIENT_NAME};
use gpui::{
actions, point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
SharedString, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations,
WindowKind, WindowOptions,
point, px, size, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SharedString,
TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind,
WindowOptions,
};
use identity::Identity;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use registry::Registry;
use smol::channel::{self, Sender};
use theme::Theme;
use ui::Root;
use crate::chatspace::ChatSpace;
use crate::actions::{load_embedded_fonts, quit, Quit};
pub(crate) mod chatspace;
pub(crate) mod views;
i18n::init!();
actions!(coop, [Quit]);
mod actions;
mod chatspace;
mod login;
mod new_identity;
mod sidebar;
mod user;
mod views;
fn main() {
// Initialize logging
tracing_subscriber::fmt::init();
// Initialize the Nostr Client
let client = nostr_client();
// Initialize the starting time
let _ = starting_time();
// Initialize the Application
let app = Application::new()
.with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
let (signal_tx, signal_rx) = channel::bounded::<NostrSignal>(2048);
let (mta_tx, mta_rx) = channel::bounded::<PublicKey>(1024);
let (event_tx, event_rx) = channel::bounded::<Event>(2048);
let signal_tx_clone = signal_tx.clone();
app.background_executor()
.spawn(async move {
// Subscribe for app updates from the bootstrap relays.
if let Err(e) = connect(client).await {
log::error!("Failed to connect to bootstrap relays: {e}");
}
// Handle Nostr notifications.
//
// Send the redefined signal back to GPUI via channel.
if let Err(e) = handle_nostr_notifications(&signal_tx_clone, &event_tx).await {
log::error!("Failed to handle Nostr notifications: {e}");
}
})
.detach();
app.background_executor()
.spawn(async move {
let duration = Duration::from_millis(METADATA_BATCH_TIMEOUT);
let mut processed_pubkeys: BTreeSet<PublicKey> = BTreeSet::new();
let mut batch: BTreeSet<PublicKey> = BTreeSet::new();
/// Internal events for the metadata batching system
enum BatchEvent {
NewKeys(PublicKey),
Timeout,
Closed,
}
loop {
let duration = smol::Timer::after(duration);
let recv = || async {
if let Ok(public_key) = mta_rx.recv().await {
BatchEvent::NewKeys(public_key)
} else {
BatchEvent::Closed
}
};
let timeout = || async {
duration.await;
BatchEvent::Timeout
};
match smol::future::or(recv(), timeout()).await {
BatchEvent::NewKeys(public_key) => {
// Prevent duplicate keys from being processed
if processed_pubkeys.insert(public_key) {
batch.insert(public_key);
}
// Process the batch if it's full
if batch.len() >= METADATA_BATCH_LIMIT {
sync_data_for_pubkeys(std::mem::take(&mut batch)).await;
}
}
BatchEvent::Timeout => {
if !batch.is_empty() {
sync_data_for_pubkeys(std::mem::take(&mut batch)).await;
}
}
BatchEvent::Closed => {
if !batch.is_empty() {
sync_data_for_pubkeys(std::mem::take(&mut batch)).await;
}
break;
}
}
}
})
.detach();
app.background_executor()
.spawn(async move {
let mut counter = 0;
loop {
// Signer is unset, probably user is not ready to retrieve gift wrap events
if client.signer().await.is_err() {
continue;
}
let duration = smol::Timer::after(Duration::from_secs(WAIT_FOR_FINISH));
let recv = || async {
// no inline
(event_rx.recv().await).ok()
};
let timeout = || async {
duration.await;
None
};
match smol::future::or(recv(), timeout()).await {
Some(event) => {
let cached = try_unwrap_event(&event, &signal_tx, &mta_tx).await;
// Increment the total messages counter if message is not from cache
if !cached {
counter += 1;
}
// Send partial finish signal to GPUI
if counter >= 20 {
signal_tx.send(NostrSignal::PartialFinish).await.ok();
// Reset counter
counter = 0;
}
}
None => {
// Notify the UI that the processing is finished
signal_tx.send(NostrSignal::Finish).await.ok();
}
}
}
})
.detach();
// Run application
app.run(move |cx| {
// Load embedded fonts in assets/fonts
@@ -204,7 +61,7 @@ fn main() {
kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(APP_NAME)),
title: Some(SharedString::new_static(CLIENT_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
@@ -213,360 +70,49 @@ fn main() {
// Open a window with default options
cx.open_window(opts, |window, cx| {
// Automatically sync theme with system appearance
window
.observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx);
})
.detach();
// Bring the app to the foreground
cx.activate(true);
// Root Entity
cx.new(|cx| {
cx.activate(true);
// Initialize the tokio runtime
gpui_tokio::init(cx);
// Initialize components
ui::init(cx);
// Initialize app registry
registry::init(cx);
// Initialize theme registry
theme::init(cx);
// Initialize backend for keys storage
key_store::init(cx);
// Initialize the nostr client
state::init(cx);
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(cx);
// Initialize settings
settings::init(cx);
// Initialize client keys
client_keys::init(cx);
// Initialize identity
identity::init(window, cx);
// Initialize relay auth registry
relay_auth::init(window, cx);
// Initialize app registry
chat::init(cx);
// Initialize person registry
person::init(cx);
// Initialize auto update
auto_update::init(cx);
// Spawn a task to handle events from nostr channel
cx.spawn_in(window, async move |_, cx| {
while let Ok(signal) = signal_rx.recv().await {
cx.update(|window, cx| {
let registry = Registry::global(cx);
let identity = Identity::global(cx);
match signal {
// Load chat rooms and stop the loading status
NostrSignal::Finish => {
registry.update(cx, |this, cx| {
this.load_rooms(window, cx);
this.set_loading(false, cx);
// Send a signal to refresh all opened rooms' messages
if let Some(ids) = ChatSpace::all_panels(window, cx) {
this.refresh_rooms(ids, cx);
}
});
}
// Load chat rooms without setting as finished
NostrSignal::PartialFinish => {
registry.update(cx, |this, cx| {
this.load_rooms(window, cx);
// Send a signal to refresh all opened rooms' messages
if let Some(ids) = ChatSpace::all_panels(window, cx) {
this.refresh_rooms(ids, cx);
}
});
}
// Add the new metadata to the registry or update the existing one
NostrSignal::Metadata(event) => {
registry.update(cx, |this, cx| {
this.insert_or_update_person(event, cx);
});
}
// Convert the gift wrapped message to a message
NostrSignal::GiftWrap(event) => {
if let Some(public_key) = identity.read(cx).public_key() {
registry.update(cx, |this, cx| {
this.event_to_message(public_key, event, window, cx);
});
}
}
NostrSignal::DmRelaysFound => {
identity.update(cx, |this, cx| {
this.set_has_dm_relays(cx);
});
}
NostrSignal::Notice(_msg) => {
// window.push_notification(msg, cx);
}
};
})
.ok();
}
})
.detach();
// Root Entity
Root::new(chatspace::init(window, cx).into(), window, cx)
})
})
.expect("Failed to open window. Please restart the application.");
});
}
fn load_embedded_fonts(cx: &App) {
let asset_source = cx.asset_source();
let font_paths = asset_source.list("fonts").unwrap();
let embedded_fonts = Mutex::new(Vec::new());
let executor = cx.background_executor();
executor.block(executor.scoped(|scope| {
for font_path in &font_paths {
if !font_path.ends_with(".ttf") {
continue;
}
scope.spawn(async {
let font_bytes = asset_source.load(font_path).unwrap().unwrap();
embedded_fonts.lock().unwrap().push(font_bytes);
});
}
}));
cx.text_system()
.add_fonts(embedded_fonts.into_inner().unwrap())
.unwrap();
}
fn quit(_: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();
}
async fn connect(client: &Client) -> Result<(), Error> {
for relay in BOOTSTRAP_RELAYS.into_iter() {
client.add_relay(relay).await?;
}
log::info!("Connected to bootstrap relays");
for relay in SEARCH_RELAYS.into_iter() {
client.add_relay(relay).await?;
}
log::info!("Connected to search relays");
// Establish connection to relays
client.connect().await;
Ok(())
}
async fn handle_nostr_notifications(
signal_tx: &Sender<NostrSignal>,
event_tx: &Sender<Event>,
) -> Result<(), Error> {
let client = nostr_client();
let auto_close = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let mut notifications = client.notifications();
while let Ok(notification) = notifications.recv().await {
let RelayPoolNotification::Message { message, .. } = notification else {
continue;
};
let RelayMessage::Event { event, .. } = message else {
continue;
};
// Skip events that have already been processed
if !processed_events().write().await.insert(event.id) {
continue;
}
match event.kind {
Kind::RelayList => {
// Get metadata for event's pubkey that matches the current user's pubkey
if let Ok(true) = is_from_current_user(&event).await {
let sub_id = SubscriptionId::new("metadata");
let filter = Filter::new()
.kinds(vec![Kind::Metadata, Kind::ContactList, Kind::InboxRelays])
.author(event.pubkey)
.limit(10);
client
.subscribe_with_id(sub_id, filter, Some(auto_close))
.await
.ok();
}
}
Kind::InboxRelays => {
if let Ok(true) = is_from_current_user(&event).await {
// Get all inbox relays
let relays = event
.tags
.filter_standardized(TagKind::Relay)
.filter_map(|t| {
if let TagStandard::Relay(url) = t {
Some(url.to_owned())
} else {
None
}
})
.collect_vec();
if !relays.is_empty() {
// Add relays to nostr client
for relay in relays.iter() {
_ = client.add_relay(relay).await;
_ = client.connect_relay(relay).await;
}
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(event.pubkey);
let sub_id = SubscriptionId::new("gift-wrap");
// Notify the UI that the current user has set up the DM relays
signal_tx.send(NostrSignal::DmRelaysFound).await.ok();
if client
.subscribe_with_id_to(relays.clone(), sub_id, filter, None)
.await
.is_ok()
{
log::info!("Subscribing to messages in: {relays:?}");
}
}
}
}
Kind::ContactList => {
if let Ok(true) = is_from_current_user(&event).await {
let public_keys: Vec<PublicKey> = event.tags.public_keys().copied().collect();
let kinds = vec![Kind::Metadata, Kind::ContactList];
let lens = public_keys.len() * kinds.len();
let filter = Filter::new().limit(lens).authors(public_keys).kinds(kinds);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(auto_close))
.await
.ok();
}
}
Kind::Metadata => {
signal_tx
.send(NostrSignal::Metadata(event.into_owned()))
.await
.ok();
}
Kind::GiftWrap => {
event_tx.send(event.into_owned()).await.ok();
}
_ => {}
}
}
Ok(())
}
async fn is_from_current_user(event: &Event) -> Result<bool, Error> {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
Ok(public_key == event.pubkey)
}
async fn sync_data_for_pubkeys(public_keys: BTreeSet<PublicKey>) {
let client = nostr_client();
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let kinds = vec![Kind::Metadata, Kind::ContactList];
let filter = Filter::new()
.limit(public_keys.len() * kinds.len())
.authors(public_keys)
.kinds(kinds);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
.ok();
}
/// Stores an unwrapped event in local database with reference to original
async fn set_unwrapped(root: EventId, unwrapped: &Event) -> Result<(), Error> {
let client = nostr_client();
// Save unwrapped event
client.database().save_event(unwrapped).await?;
// Create a reference event pointing to the unwrapped event
let event = EventBuilder::new(Kind::ApplicationSpecificData, "")
.tags(vec![Tag::identifier(root), Tag::event(unwrapped.id)])
.sign(&Keys::generate())
.await?;
// Save reference event
client.database().save_event(&event).await?;
Ok(())
}
/// Retrieves a previously unwrapped event from local database
async fn get_unwrapped(root: EventId) -> Result<Event, Error> {
let client = nostr_client();
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(root)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
let target_id = event.tags.event_ids().collect_vec()[0];
if let Some(event) = client.database().event_by_id(target_id).await? {
Ok(event)
} else {
Err(anyhow!("Event not found."))
}
} else {
Err(anyhow!("Event is not cached yet."))
}
}
/// Unwraps a gift-wrapped event and processes its contents.
async fn try_unwrap_event(
event: &Event,
signal_tx: &Sender<NostrSignal>,
mta_tx: &Sender<PublicKey>,
) -> bool {
let client = nostr_client();
let mut is_cached = false;
let event = match get_unwrapped(event.id).await {
Ok(event) => {
is_cached = true;
event
}
Err(_) => {
match client.unwrap_gift_wrap(event).await {
Ok(unwrap) => {
// Sign the unwrapped event with a RANDOM KEYS
let Ok(unwrapped) = unwrap.rumor.sign_with_keys(&Keys::generate()) else {
log::error!("Failed to sign event");
return false;
};
// Save this event to the database for future use.
if let Err(e) = set_unwrapped(event.id, &unwrapped).await {
log::warn!("Failed to cache unwrapped event: {e}")
}
unwrapped
}
Err(e) => {
log::error!("Failed to unwrap event: {e}");
return false;
}
}
}
};
// Send all pubkeys to the metadata batch to sync data
for public_key in event.all_pubkeys() {
mta_tx.send(public_key).await.ok();
}
// Send a notify to GPUI if this is a new message
if starting_time() <= &event.created_at {
signal_tx.send(NostrSignal::GiftWrap(event)).await.ok();
}
is_cached
}

View File

@@ -0,0 +1,217 @@
use std::time::Duration;
use anyhow::{anyhow, Error};
use common::home_dir;
use gpui::{
div, App, AppContext, ClipboardItem, Context, Entity, IntoElement, ParentElement, Render,
SharedString, Styled, Task, Window,
};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt};
pub fn init(keys: &Keys, window: &mut Window, cx: &mut App) -> Entity<Backup> {
cx.new(|cx| Backup::new(keys, window, cx))
}
#[derive(Debug)]
pub struct Backup {
pubkey_input: Entity<InputState>,
secret_input: Entity<InputState>,
error: Option<SharedString>,
copied: bool,
// Async operations
_tasks: SmallVec<[Task<()>; 1]>,
}
impl Backup {
pub fn new(keys: &Keys, window: &mut Window, cx: &mut Context<Self>) -> Self {
let Ok(npub) = keys.public_key.to_bech32();
let Ok(nsec) = keys.secret_key().to_bech32();
let pubkey_input = cx.new(|cx| {
InputState::new(window, cx)
.disabled(true)
.default_value(npub)
});
let secret_input = cx.new(|cx| {
InputState::new(window, cx)
.disabled(true)
.default_value(nsec)
});
Self {
pubkey_input,
secret_input,
error: None,
copied: false,
_tasks: smallvec![],
}
}
pub fn backup(&self, window: &Window, cx: &Context<Self>) -> Task<Result<(), Error>> {
let dir = home_dir();
let path = cx.prompt_for_new_path(dir, Some("My Nostr Account"));
let nsec = self.secret_input.read(cx).value().to_string();
cx.spawn_in(window, async move |this, cx| {
match path.await {
Ok(Ok(Some(path))) => {
if let Err(e) = smol::fs::write(&path, nsec).await {
this.update_in(cx, |this, window, cx| {
this.set_error(e.to_string(), window, cx);
})
.expect("Entity has been released");
} else {
return Ok(());
}
}
_ => {
log::error!("Failed to save backup keys");
}
};
Err(anyhow!("Failed to backup keys"))
})
}
fn copy(&mut self, value: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) {
let item = ClipboardItem::new_string(value.into());
cx.write_to_clipboard(item);
self.set_copied(true, window, cx);
}
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
// Reset the copied state after a delay
if status {
self._tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
this.update_in(cx, |this, window, cx| {
this.set_copied(false, window, cx);
})
.ok();
}));
}
}
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
where
E: Into<SharedString>,
{
self.error = Some(error.into());
cx.notify();
// Clear the error message after a delay
self._tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})
.ok();
}));
}
}
impl Render for Backup {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const DESCRIPTION: &str = "In Nostr, your account is defined by a KEY PAIR. These keys are used to sign your messages and identify you.";
const WARN: &str = "You must keep the Secret Key in a safe place. If you lose it, you will lose access to your account.";
const PK: &str = "Public Key is the address that others will use to find you.";
const SK: &str = "Secret Key provides access to your account.";
v_flex()
.gap_2()
.text_sm()
.child(SharedString::from(DESCRIPTION))
.child(
v_flex()
.gap_1()
.child(
div()
.font_semibold()
.child(SharedString::from("Public Key:")),
)
.child(
h_flex()
.gap_1()
.child(TextInput::new(&self.pubkey_input).small())
.child(
Button::new("copy-pubkey")
.icon({
if self.copied {
IconName::CheckCircleFill
} else {
IconName::Copy
}
})
.ghost_alt()
.disabled(self.copied)
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy(this.pubkey_input.read(cx).value(), window, cx);
})),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(PK)),
),
)
.child(divider(cx))
.child(
v_flex()
.gap_1()
.child(
div()
.font_semibold()
.child(SharedString::from("Secret Key:")),
)
.child(
h_flex()
.gap_1()
.child(TextInput::new(&self.secret_input).small())
.child(
Button::new("copy-secret")
.icon({
if self.copied {
IconName::CheckCircleFill
} else {
IconName::Copy
}
})
.ghost_alt()
.disabled(self.copied)
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy(this.secret_input.read(cx).value(), window, cx);
})),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(SK)),
),
)
.child(divider(cx))
.child(
div()
.text_xs()
.text_color(cx.theme().danger_foreground)
.child(SharedString::from(WARN)),
)
}
}

View File

@@ -0,0 +1,350 @@
use anyhow::{anyhow, Error};
use common::{default_nip17_relays, default_nip65_relays, nip96_upload, BOOTSTRAP_RELAYS};
use gpui::{
rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task, Window,
};
use gpui_tokio::Tokio;
use key_store::{KeyItem, KeyStore};
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs;
use state::NostrRegistry;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable};
mod backup;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
cx.new(|cx| NewAccount::new(window, cx))
}
#[derive(Debug)]
pub struct NewAccount {
name_input: Entity<InputState>,
avatar_input: Entity<InputState>,
temp_keys: Entity<Keys>,
uploading: bool,
submitting: bool,
// Panel
name: SharedString,
focus_handle: FocusHandle,
}
impl NewAccount {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let temp_keys = cx.new(|_| Keys::generate());
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
let avatar_input = cx.new(|cx| InputState::new(window, cx));
Self {
name_input,
avatar_input,
temp_keys,
uploading: false,
submitting: false,
name: "Create a new identity".into(),
focus_handle: cx.focus_handle(),
}
}
fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.submitting(true, cx);
let keys = self.temp_keys.read(cx).clone();
let view = backup::init(&keys, window, cx);
let weak_view = view.downgrade();
let current_view = cx.entity().downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
let current_view = current_view.clone();
modal
.alert()
.title(SharedString::from(
"Backup to avoid losing access to your account",
))
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text("Download"))
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
let view = current_view.clone();
let task = this.backup(window, cx);
cx.spawn_in(window, async move |_this, cx| {
let result = task.await;
match result {
Ok(_) => {
view.update_in(cx, |this, window, cx| {
this.set_signer(window, cx);
})
.expect("Entity has been released");
}
Err(e) => {
log::error!("Failed to backup: {e}");
}
}
})
.detach();
})
.ok();
// true to close the modal
false
})
})
}
pub fn set_signer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let keystore = KeyStore::global(cx).read(cx).backend();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let keys = self.temp_keys.read(cx).clone();
let username = keys.public_key().to_hex();
let secret = keys.secret_key().to_secret_hex().into_bytes();
let avatar = self.avatar_input.read(cx).value().to_string();
let name = self.name_input.read(cx).value().to_string();
let mut metadata = Metadata::new().display_name(name.clone()).name(name);
if let Ok(url) = Url::parse(&avatar) {
metadata = metadata.picture(url);
};
// Close all modals if available
window.close_all_modals(cx);
// Set the client's signer with the current keys
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = keys.clone();
let nip65_relays = default_nip65_relays();
let nip17_relays = default_nip17_relays();
// Construct a NIP-65 event
let event = EventBuilder::new(Kind::RelayList, "")
.tags(
nip65_relays
.iter()
.cloned()
.map(|(url, metadata)| Tag::relay_metadata(url, metadata)),
)
.sign(&signer)
.await?;
// Set NIP-65 relays
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
// Extract only write relays
let write_relays: Vec<RelayUrl> = nip65_relays
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
Some(url.to_owned())
} else {
None
}
})
.collect();
// Ensure relays are connected
for url in write_relays.iter() {
client.add_relay(url).await?;
client.connect_relay(url).await?;
}
// Construct a NIP-17 event
let event = EventBuilder::new(Kind::InboxRelays, "")
.tags(nip17_relays.iter().cloned().map(Tag::relay))
.sign(&signer)
.await?;
// Set NIP-17 relays
client.send_event_to(&write_relays, &event).await?;
// Construct a metadata event
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
// Send metadata event to both write relays and bootstrap relays
client.send_event_to(&write_relays, &event).await?;
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
// Update the client's signer with the current keys
client.set_signer(keys).await;
Ok(())
});
cx.spawn_in(window, async move |this, cx| {
let url = KeyItem::User.to_string();
// Write the app keys for further connection
keystore
.write_credentials(&url, &username, &secret, cx)
.await
.ok();
if let Err(e) = task.await {
this.update_in(cx, |this, window, cx| {
this.submitting(false, cx);
window.push_notification(e.to_string(), cx);
})
.expect("Entity has been released");
}
})
.detach();
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.uploading(true, cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get the user's configured NIP96 server
let nip96_server = AppSettings::get_media_server(cx);
// Open native file dialog
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
let task = Tokio::spawn(cx, async move {
match paths.await {
Ok(Ok(Some(mut paths))) => {
if let Some(path) = paths.pop() {
let file = fs::read(path).await?;
let url = nip96_upload(&client, &nip96_server, file).await?;
Ok(url)
} else {
Err(anyhow!("Path not found"))
}
}
_ => Err(anyhow!("Error")),
}
});
cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(Ok(url)) => {
this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
});
}
Ok(Err(e)) => {
window.push_notification(e.to_string(), cx);
}
Err(e) => {
log::warn!("Failed to upload avatar: {e}");
}
};
this.uploading(false, cx);
})
.expect("Entity has been released");
})
.detach();
}
fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.submitting = status;
cx.notify();
}
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.uploading = status;
cx.notify();
}
}
impl Panel for NewAccount {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for NewAccount {}
impl Focusable for NewAccount {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for NewAccount {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar = self.avatar_input.read(cx).value();
v_flex()
.size_full()
.relative()
.items_center()
.justify_center()
.child(
v_flex()
.w_96()
.gap_2()
.child(
v_flex()
.h_40()
.w_full()
.items_center()
.justify_center()
.gap_4()
.child(Avatar::new(avatar).size(rems(4.25)))
.child(
Button::new("upload")
.icon(IconName::PlusCircleFill)
.label("Add an avatar")
.xsmall()
.ghost()
.rounded()
.disabled(self.uploading)
//.loading(self.uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(SharedString::from("What should people call you?"))
.child(
TextInput::new(&self.name_input)
.disabled(self.submitting)
.small(),
),
)
.child(divider(cx))
.child(
Button::new("submit")
.label("Continue")
.primary()
.loading(self.submitting)
.disabled(self.submitting || self.uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.create(window, cx);
})),
),
)
}
}

View File

@@ -1,17 +1,15 @@
use std::rc::Rc;
use chat::{ChatRegistry, RoomKind};
use chat_ui::{CopyPublicKey, OpenPublicKey};
use gpui::prelude::FluentBuilder;
use gpui::{
div, rems, App, ClickEvent, Div, InteractiveElement, IntoElement, ParentElement as _,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window,
};
use i18n::t;
use nostr_sdk::prelude::*;
use registry::room::RoomKind;
use registry::Registry;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::actions::OpenProfile;
use ui::avatar::Avatar;
use ui::context_menu::ContextMenuExt;
use ui::modal::ModalButtonProps;
@@ -23,7 +21,6 @@ use crate::views::screening;
#[derive(IntoElement)]
pub struct RoomListItem {
ix: usize,
base: Div,
room_id: Option<u64>,
public_key: Option<PublicKey>,
name: Option<SharedString>,
@@ -38,7 +35,6 @@ impl RoomListItem {
pub fn new(ix: usize) -> Self {
Self {
ix,
base: h_flex().h_9().w_full().px_1p5().gap_2(),
room_id: None,
public_key: None,
name: None,
@@ -59,18 +55,18 @@ impl RoomListItem {
self
}
pub fn name(mut self, name: SharedString) -> Self {
self.name = Some(name);
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
self.name = Some(name.into());
self
}
pub fn avatar(mut self, avatar: SharedString) -> Self {
self.avatar = Some(avatar);
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
self.avatar = Some(avatar.into());
self
}
pub fn created_at(mut self, created_at: SharedString) -> Self {
self.created_at = Some(created_at);
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
self.created_at = Some(created_at.into());
self
}
@@ -111,22 +107,29 @@ impl RenderOnce for RoomListItem {
self.handler,
)
else {
return self
.base
return h_flex()
.id(self.ix)
.h_9()
.w_full()
.px_1p5()
.gap_2()
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
.child(
div()
.flex_1()
.flex()
.justify_between()
.child(Skeleton::new().w_32().h_2p5().rounded_sm())
.child(Skeleton::new().w_6().h_2p5().rounded_sm()),
.child(Skeleton::new().w_32().h_2p5().rounded(cx.theme().radius))
.child(Skeleton::new().w_6().h_2p5().rounded(cx.theme().radius)),
);
};
self.base
h_flex()
.id(self.ix)
.h_9()
.w_full()
.px_1p5()
.gap_2()
.text_sm()
.rounded(cx.theme().radius)
.when(!hide_avatar, |this| {
@@ -162,11 +165,11 @@ impl RenderOnce for RoomListItem {
.child(created_at),
),
)
.context_menu(move |this, _window, _cx| {
// TODO: add share chat room
this.menu(t!("profile.view"), Box::new(OpenProfile(public_key)))
})
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.context_menu(move |this, _window, _cx| {
this.menu("View Profile", Box::new(OpenPublicKey(public_key)))
.menu("Copy Public Key", Box::new(CopyPublicKey(public_key)))
})
.on_click(move |event, window, cx| {
handler(event, window, cx);
@@ -178,11 +181,11 @@ impl RenderOnce for RoomListItem {
.child(screening.clone())
.button_props(
ModalButtonProps::default()
.cancel_text(t!("screening.ignore"))
.ok_text(t!("screening.response")),
.cancel_text("Ignore")
.ok_text("Response"),
)
.on_cancel(move |_event, _window, cx| {
Registry::global(cx).update(cx, |this, cx| {
ChatRegistry::global(cx).update(cx, |this, cx| {
this.close_room(room_id, cx);
});
// false to prevent closing the modal

View File

@@ -0,0 +1,791 @@
use std::ops::Range;
use std::time::Duration;
use anyhow::{anyhow, Error};
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::{DebouncedDelay, RenderedTimestamp, TextUtils, BOOTSTRAP_RELAYS, SEARCH_RELAYS};
use gpui::prelude::FluentBuilder;
use gpui::{
deferred, div, relative, uniform_list, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
RetainAllImageCache, SharedString, Styled, Subscription, Task, Window,
};
use gpui_tokio::Tokio;
use list_item::RoomListItem;
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, GIFTWRAP_SUBSCRIPTION};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::popup_menu::PopupMenuExt;
use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Selectable, Sizable, StyledExt};
use crate::actions::{RelayStatus, Reload};
mod list_item;
const FIND_DELAY: u64 = 600;
const FIND_LIMIT: usize = 20;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
cx.new(|cx| Sidebar::new(window, cx))
}
/// Sidebar.
pub struct Sidebar {
name: SharedString,
/// Focus handle for the sidebar
focus_handle: FocusHandle,
/// Image cache
image_cache: Entity<RetainAllImageCache>,
/// Search results
search_results: Entity<Option<Vec<Entity<Room>>>>,
/// Async search operation
search_task: Option<Task<()>>,
/// Search input state
find_input: Entity<InputState>,
/// Debounced delay for search input
find_debouncer: DebouncedDelay<Self>,
/// Whether searching is in progress
finding: bool,
/// New request flag
new_request: bool,
/// Current chat room filter
active_filter: Entity<RoomKind>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl Sidebar {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let active_filter = cx.new(|_| RoomKind::Ongoing);
let search_results = cx.new(|_| None);
// Define the find input state
let find_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Find or start a conversation")
.clean_on_escape()
});
// Get the chat registry
let chat = ChatRegistry::global(cx);
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe for registry new events
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
if event == &ChatEvent::Ping {
this.new_request = true;
cx.notify();
};
}),
);
subscriptions.push(
// Subscribe for find input events
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
let delay = Duration::from_millis(FIND_DELAY);
match event {
InputEvent::PressEnter { .. } => {
this.search(window, cx);
}
InputEvent::Change => {
if state.read(cx).value().is_empty() {
// Clear the result when input is empty
this.clear(window, cx);
} else {
// Run debounced search
this.find_debouncer
.fire_new(delay, window, cx, |this, window, cx| {
this.debounced_search(window, cx)
});
}
}
_ => {}
};
}),
);
Self {
name: "Sidebar".into(),
focus_handle: cx.focus_handle(),
image_cache: RetainAllImageCache::new(cx),
find_debouncer: DebouncedDelay::new(),
finding: false,
new_request: false,
active_filter,
find_input,
search_results,
search_task: None,
_subscriptions: subscriptions,
}
}
async fn nip50(client: &Client, query: &str) -> Result<Vec<Event>, Error> {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::Metadata)
.search(query.to_lowercase())
.limit(FIND_LIMIT);
let mut stream = client
.stream_events_from(SEARCH_RELAYS, filter, Duration::from_secs(3))
.await?;
let mut results: Vec<Event> = Vec::with_capacity(FIND_LIMIT);
while let Some((_url, event)) = stream.next().await {
if let Ok(event) = event {
// Skip if author is match current user
if event.pubkey == public_key {
continue;
}
// Skip if the event has already been added
if results.iter().any(|this| this.pubkey == event.pubkey) {
continue;
}
results.push(event);
}
}
if results.is_empty() {
return Err(anyhow!("No results for query {query}"));
}
// Get all public keys
let public_keys: Vec<PublicKey> = results.iter().map(|event| event.pubkey).collect();
// Fetch metadata and contact lists if public keys is not empty
if !public_keys.is_empty() {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new()
.kinds(vec![Kind::Metadata, Kind::ContactList])
.limit(public_keys.len() * 2)
.authors(public_keys);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await?;
}
Ok(results)
}
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, window, cx| {
this.search(window, cx);
})
.ok();
})
}
fn search_by_nip50(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let query = query.to_owned();
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
let result = Self::nip50(&client, &query).await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(results) => {
let rooms = results
.into_iter()
.map(|event| {
cx.new(|_| Room::new(None, public_key, vec![event.pubkey]))
})
.collect();
this.set_results(rooms, cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
};
this.set_finding(false, window, cx);
})
.ok();
}));
}
fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let address = query.to_owned();
let task = Tokio::spawn(cx, async move {
match common::nip05_profile(&address).await {
Ok(profile) => {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let receivers = vec![profile.public_key];
let room = Room::new(None, public_key, receivers);
Ok(room)
}
Err(e) => Err(anyhow!(e)),
}
});
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(Ok(room)) => {
this.set_results(vec![cx.new(|_| room)], cx);
}
Ok(Err(e)) => {
window.push_notification(e.to_string(), cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
}
this.set_finding(false, window, cx);
})
.ok();
}));
}
fn search_by_pubkey(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Ok(public_key) = query.to_public_key() else {
window.push_notification("Public Key is invalid", cx);
self.set_finding(false, window, cx);
return;
};
let task: Task<Result<Room, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let author = signer.get_public_key().await?;
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let receivers = vec![public_key];
let room = Room::new(None, author, receivers);
let filter = Filter::new()
.kinds(vec![Kind::Metadata, Kind::ContactList])
.author(public_key)
.limit(2);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await?;
Ok(room)
});
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(room) => {
let chat = ChatRegistry::global(cx);
let local_results = chat.read(cx).search_by_public_key(public_key, cx);
if !local_results.is_empty() {
this.set_results(local_results, cx);
} else {
this.set_results(vec![cx.new(|_| room)], cx);
}
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
};
this.set_finding(false, window, cx);
})
.ok();
}));
}
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Return if the query is empty
if self.find_input.read(cx).value().is_empty() {
return;
}
// Return if search is in progress
if self.finding {
if self.search_task.is_none() {
window.push_notification("There is another search in progress", cx);
return;
} else {
// Cancel ongoing search request
self.search_task = None;
}
}
let input = self.find_input.read(cx).value();
let query = input.to_string();
// Block the input until the search process completes
self.set_finding(true, window, cx);
// Process to search by pubkey if query starts with npub or nprofile
if query.starts_with("npub1") || query.starts_with("nprofile1") {
self.search_by_pubkey(&query, window, cx);
return;
};
// Process to search by NIP05 if query is a valid NIP-05 identifier (name@domain.tld)
if query.split('@').count() == 2 {
let parts: Vec<&str> = query.split('@').collect();
if !parts[0].is_empty() && !parts[1].is_empty() && parts[1].contains('.') {
self.search_by_nip05(&query, window, cx);
return;
}
}
// Get all local results with current query
let chat = ChatRegistry::global(cx);
let local_results = chat.read(cx).search(&query, cx);
// Try to update with local results first
if !local_results.is_empty() {
self.set_results(local_results, cx);
return;
};
// If no local results, try global search via NIP-50
self.search_by_nip50(&query, window, cx);
}
fn set_results(&mut self, rooms: Vec<Entity<Room>>, cx: &mut Context<Self>) {
self.search_results.update(cx, |this, cx| {
*this = Some(rooms);
cx.notify();
});
}
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
// Disable the input to prevent duplicate requests
self.find_input.update(cx, |this, cx| {
this.set_disabled(status, cx);
this.set_loading(status, cx);
});
// Set the finding status
self.finding = status;
cx.notify();
}
fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Reset the input state
if self.finding {
self.set_finding(false, window, cx);
}
// Clear all local results
self.search_results.update(cx, |this, cx| {
*this = None;
cx.notify();
});
}
fn filter(&self, kind: &RoomKind, cx: &Context<Self>) -> bool {
self.active_filter.read(cx) == kind
}
fn set_filter(&mut self, kind: RoomKind, cx: &mut Context<Self>) {
self.active_filter.update(cx, |this, cx| {
*this = kind;
cx.notify();
});
self.new_request = false;
cx.notify();
}
fn open(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
let chat = ChatRegistry::global(cx);
match chat.read(cx).room(&id, cx) {
Some(room) => {
chat.update(cx, |this, cx| {
this.emit_room(room, cx);
});
}
None => {
if let Some(room) = self
.search_results
.read(cx)
.as_ref()
.and_then(|results| results.iter().find(|this| this.read(cx).id == id))
.map(|this| this.downgrade())
{
chat.update(cx, |this, cx| {
this.emit_room(room, cx);
});
// Clear all search results
self.clear(window, cx);
}
}
}
}
fn on_reload(&mut self, _ev: &Reload, window: &mut Window, cx: &mut Context<Self>) {
ChatRegistry::global(cx).update(cx, |this, cx| {
this.get_rooms(cx);
});
window.push_notification("Reload", cx);
}
fn on_manage(&mut self, _ev: &RelayStatus, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let task: Task<Result<Vec<Relay>, Error>> = cx.background_spawn(async move {
let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
let subscription = client.subscription(&id).await;
let mut relays: Vec<Relay> = vec![];
for (url, _filter) in subscription.into_iter() {
relays.push(client.pool().relay(url).await?);
}
Ok(relays)
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(relays) = task.await {
this.update_in(cx, |this, window, cx| {
this.manage_relays(relays, window, cx);
})
.ok();
}
})
.detach();
}
fn manage_relays(&mut self, relays: Vec<Relay>, window: &mut Window, cx: &mut Context<Self>) {
window.open_modal(cx, move |this, _window, cx| {
this.show_close(true)
.overlay_closable(true)
.keyboard(true)
.title(SharedString::from("Messaging Relay Status"))
.child(v_flex().pb_4().gap_2().children({
let mut items = Vec::with_capacity(relays.len());
for relay in relays.clone().into_iter() {
let url = relay.url().to_string();
let time = relay.stats().connected_at().to_ago();
let connected = relay.is_connected();
items.push(
h_flex()
.h_8()
.px_2()
.justify_between()
.text_xs()
.bg(cx.theme().elevated_surface_background)
.rounded(cx.theme().radius)
.child(
h_flex()
.gap_1()
.font_semibold()
.child(
Icon::new(IconName::Signal)
.small()
.text_color(cx.theme().danger_active)
.when(connected, |this| {
this.text_color(gpui::green().alpha(0.75))
}),
)
.child(url),
)
.child(
div().text_right().text_color(cx.theme().text_muted).child(
SharedString::from(format!("Last activity: {}", time)),
),
),
);
}
items
}))
});
}
fn list_items(
&self,
rooms: &[Entity<Room>],
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement> {
let mut items = Vec::with_capacity(range.end - range.start);
for ix in range {
let Some(room) = rooms.get(ix) else {
items.push(RoomListItem::new(ix));
continue;
};
let this = room.read(cx);
let room_id = this.id;
let member = this.display_member(cx);
let handler = cx.listener({
move |this, _, window, cx| {
this.open(room_id, window, cx);
}
});
items.push(
RoomListItem::new(ix)
.room_id(room_id)
.name(this.display_name(cx))
.avatar(this.display_image(cx))
.public_key(member.public_key())
.kind(this.kind)
.created_at(this.created_at.to_ago())
.on_click(handler),
)
}
items
}
}
impl Panel for Sidebar {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
}
impl EventEmitter<PanelEvent> for Sidebar {}
impl Focusable for Sidebar {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let chat = ChatRegistry::global(cx);
let loading = chat.read(cx).loading();
// Get rooms from either search results or the chat registry
let rooms = if let Some(results) = self.search_results.read(cx).as_ref() {
results.to_owned()
} else {
// Filter rooms based on the active filter
if self.active_filter.read(cx) == &RoomKind::Ongoing {
chat.read(cx).ongoing_rooms(cx)
} else {
chat.read(cx).request_rooms(cx)
}
};
// Get total rooms count
let mut total_rooms = rooms.len();
// Add 3 dummy rooms to display as skeletons
if loading {
total_rooms += 3
}
v_flex()
.on_action(cx.listener(Self::on_reload))
.on_action(cx.listener(Self::on_manage))
.image_cache(self.image_cache.clone())
.size_full()
.relative()
.gap_3()
// Search Input
.child(
div()
.relative()
.mt_3()
.px_2p5()
.w_full()
.h_7()
.flex_none()
.flex()
.child(
TextInput::new(&self.find_input)
.small()
.cleanable()
.appearance(true)
.text_xs()
.map(|this| {
if !self.find_input.read(cx).loading {
this.suffix(
Button::new("find")
.icon(IconName::Search)
.tooltip("Press Enter to search")
.transparent()
.small(),
)
} else {
this
}
}),
),
)
// Chat Rooms
.child(
v_flex()
.gap_1()
.flex_1()
.px_1p5()
.w_full()
.overflow_y_hidden()
.child(
div()
.px_1()
.h_flex()
.gap_2()
.flex_none()
.child(
Button::new("all")
.label("All")
.tooltip("All ongoing conversations")
.small()
.cta()
.bold()
.secondary()
.rounded()
.selected(self.filter(&RoomKind::Ongoing, cx))
.on_click(cx.listener(|this, _, _, cx| {
this.set_filter(RoomKind::Ongoing, cx);
})),
)
.child(
Button::new("requests")
.label("Requests")
.tooltip("Incoming new conversations")
.when(self.new_request, |this| {
this.child(
div().size_1().rounded_full().bg(cx.theme().cursor),
)
})
.small()
.cta()
.bold()
.secondary()
.rounded()
.selected(!self.filter(&RoomKind::Ongoing, cx))
.on_click(cx.listener(|this, _, _, cx| {
this.set_filter(RoomKind::default(), cx);
})),
)
.child(
h_flex()
.flex_1()
.w_full()
.justify_end()
.items_center()
.text_xs()
.child(
Button::new("option")
.icon(IconName::Ellipsis)
.xsmall()
.ghost()
.rounded()
.popup_menu(move |this, _window, _cx| {
this.menu(
"Reload",
Box::new(Reload),
)
.menu(
"Relay Status",
Box::new(RelayStatus),
)
}),
),
),
)
.when(!loading && total_rooms == 0, |this| {
this.map(|this| {
if self.filter(&RoomKind::Ongoing, cx) {
this.child(deferred(
v_flex()
.py_2()
.px_1p5()
.gap_1p5()
.items_center()
.justify_center()
.text_center()
.child(
div()
.text_sm()
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from("No conversations")),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.line_height(relative(1.25))
.child(SharedString::from("Start a conversation with someone to get started.")),
),
))
} else {
this.child(deferred(
v_flex()
.py_2()
.px_1p5()
.gap_1p5()
.items_center()
.justify_center()
.text_center()
.child(
div()
.text_sm()
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from("No message requests")),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.line_height(relative(1.25))
.child(SharedString::from("New message requests from people you don't know will appear here.")),
),
))
}
})
})
.child(
uniform_list(
"rooms",
total_rooms,
cx.processor(move |this, range, _window, cx| {
this.list_items(&rooms, range, cx)
}),
)
.h_full(),
),
)
}
}

388
crates/coop/src/user/mod.rs Normal file
View File

@@ -0,0 +1,388 @@
use std::str::FromStr;
use std::time::Duration;
use anyhow::{anyhow, Error};
use common::{nip96_upload, shorten_pubkey};
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, App, AppContext, ClipboardItem, Context, Entity, IntoElement, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Task, Window,
};
use gpui_tokio::Tokio;
use nostr_sdk::prelude::*;
use person::Person;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use smol::fs;
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::{h_flex, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
pub mod viewer;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<UserProfile> {
cx.new(|cx| UserProfile::new(window, cx))
}
#[derive(Debug)]
pub struct UserProfile {
/// User profile
profile: Option<Profile>,
/// User's name text input
name_input: Entity<InputState>,
/// User's avatar url text input
avatar_input: Entity<InputState>,
/// User's bio multi line input
bio_input: Entity<InputState>,
/// User's website url text input
website_input: Entity<InputState>,
/// Uploading state
uploading: bool,
/// Copied states
copied: bool,
/// Async operations
_tasks: SmallVec<[Task<()>; 1]>,
}
impl UserProfile {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me"));
// Use multi-line input for bio
let bio_input = cx.new(|cx| {
InputState::new(window, cx)
.multi_line()
.auto_grow(3, 8)
.placeholder("A short introduce about you.")
});
let get_profile = Self::get_profile(cx);
let mut tasks = smallvec![];
tasks.push(
// Get metadata in the background
cx.spawn_in(window, async move |this, cx| {
if let Ok(profile) = get_profile.await {
this.update_in(cx, |this, window, cx| {
this.set_profile(profile, window, cx);
})
.ok();
}
}),
);
Self {
profile: None,
name_input,
avatar_input,
bio_input,
website_input,
uploading: false,
copied: false,
_tasks: tasks,
}
}
fn get_profile(cx: &App) -> Task<Result<Profile, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let metadata = client
.database()
.metadata(public_key)
.await?
.unwrap_or_default();
Ok(Profile::new(public_key, metadata))
})
}
fn set_profile(&mut self, profile: Profile, window: &mut Window, cx: &mut Context<Self>) {
let metadata = profile.metadata();
self.avatar_input.update(cx, |this, cx| {
if let Some(avatar) = metadata.picture.as_ref() {
this.set_value(avatar, window, cx);
}
});
self.bio_input.update(cx, |this, cx| {
if let Some(bio) = metadata.about.as_ref() {
this.set_value(bio, window, cx);
}
});
self.name_input.update(cx, |this, cx| {
if let Some(display_name) = metadata.display_name.as_ref() {
this.set_value(display_name, window, cx);
}
});
self.website_input.update(cx, |this, cx| {
if let Some(website) = metadata.website.as_ref() {
this.set_value(website, window, cx);
}
});
self.profile = Some(profile);
cx.notify();
}
fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
let item = ClipboardItem::new_string(value);
cx.write_to_clipboard(item);
self.set_copied(true, window, cx);
}
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
if status {
self._tasks.push(
// Reset the copied state after a delay
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_copied(false, window, cx);
})
.ok();
})
.ok();
}),
);
}
}
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.uploading = status;
cx.notify();
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.uploading(true, cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get the user's configured NIP96 server
let nip96_server = AppSettings::get_media_server(cx);
// Open native file dialog
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
let task = Tokio::spawn(cx, async move {
match paths.await {
Ok(Ok(Some(mut paths))) => {
if let Some(path) = paths.pop() {
let file = fs::read(path).await?;
let url = nip96_upload(&client, &nip96_server, file).await?;
Ok(url)
} else {
Err(anyhow!("Path not found"))
}
}
_ => Err(anyhow!("Error")),
}
});
cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(Ok(url)) => {
this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
});
}
Ok(Err(e)) => {
window.push_notification(e.to_string(), cx);
}
Err(e) => {
log::warn!("Failed to upload avatar: {e}");
}
};
this.uploading(false, cx);
})
.expect("Entity has been released");
})
.detach();
}
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Person, Error>> {
let avatar = self.avatar_input.read(cx).value().to_string();
let name = self.name_input.read(cx).value().to_string();
let bio = self.bio_input.read(cx).value().to_string();
let website = self.website_input.read(cx).value().to_string();
// Get the current profile metadata
let old_metadata = self
.profile
.as_ref()
.map(|profile| profile.metadata())
.unwrap_or_default();
// Construct the new metadata
let mut new_metadata = old_metadata.display_name(name).about(bio);
if let Ok(url) = Url::from_str(&avatar) {
new_metadata = new_metadata.picture(url);
};
if let Ok(url) = Url::from_str(&website) {
new_metadata = new_metadata.website(url);
}
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
cx.background_spawn(async move {
let urls = write_relays.await;
let signer = client.signer().await?;
// Sign the new metadata event
let event = EventBuilder::metadata(&new_metadata).sign(&signer).await?;
// Send event to user's write relayss
client.send_event_to(urls, &event).await?;
// Return the updated profile
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let profile = Person::new(event.pubkey, metadata);
Ok(profile)
})
}
}
impl Render for UserProfile {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.child(
v_flex()
.relative()
.w_full()
.h_32()
.items_center()
.justify_center()
.gap_2()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.map(|this| {
let picture = self.avatar_input.read(cx).value();
let source = if picture.is_empty() {
"brand/avatar.png"
} else {
picture.as_str()
};
this.child(img(source).rounded_full().size_10().flex_shrink_0())
})
.child(
Button::new("upload")
.icon(IconName::Upload)
.label("Change")
.ghost()
.small()
.disabled(self.uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(SharedString::from("Name:"))
.child(TextInput::new(&self.name_input).small()),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(SharedString::from("Bio:"))
.child(TextInput::new(&self.bio_input).small()),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(SharedString::from("Website:"))
.child(TextInput::new(&self.website_input).small()),
)
.when_some(self.profile.as_ref(), |this, profile| {
let public_key = profile.public_key();
let display = SharedString::from(shorten_pubkey(profile.public_key(), 8));
this.child(div().my_1().h_px().w_full().bg(cx.theme().border))
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(SharedString::from("Public Key:")),
)
.child(
h_flex()
.gap_2()
.w_full()
.h_12()
.justify_center()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.text_sm()
.child(display)
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircleFill
} else {
IconName::Copy
}
})
.xsmall()
.ghost()
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy(
public_key.to_bech32().unwrap(),
window,
cx,
);
})),
),
),
)
})
}
}

View File

@@ -1,258 +1,257 @@
use std::time::Duration;
use common::display::DisplayProfile;
use common::nip05::nip05_verify;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement,
ParentElement, Render, SharedString, Styled, Task, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use nostr_sdk::prelude::*;
use registry::Registry;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, StyledExt};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<UserProfile> {
UserProfile::new(public_key, window, cx)
}
pub struct UserProfile {
public_key: PublicKey,
followed: bool,
verified: bool,
copied: bool,
}
impl UserProfile {
pub fn new(public_key: PublicKey, _window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|_| Self {
public_key,
followed: false,
verified: false,
copied: false,
})
}
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Skip if user isn't logged in
let Some(identity) = Identity::read_global(cx).public_key() else {
return;
};
let public_key = self.public_key;
let check_follow: Task<bool> = cx.background_spawn(async move {
let client = nostr_client();
let filter = Filter::new()
.kind(Kind::ContactList)
.author(identity)
.pubkey(public_key)
.limit(1);
client.database().count(filter).await.unwrap_or(0) >= 1
});
let verify_nip05 = if let Some(address) = self.address(cx) {
Some(Tokio::spawn(cx, async move {
nip05_verify(public_key, &address).await.unwrap_or(false)
}))
} else {
None
};
cx.spawn_in(window, async move |this, cx| {
let followed = check_follow.await;
// Update the followed status
this.update(cx, |this, cx| {
this.followed = followed;
cx.notify();
})
.ok();
// Update the NIP05 verification status if user has NIP05 address
if let Some(task) = verify_nip05 {
if let Ok(verified) = task.await {
this.update(cx, |this, cx| {
this.verified = verified;
cx.notify();
})
.ok();
}
}
})
.detach();
}
fn profile(&self, cx: &Context<Self>) -> Profile {
let registry = Registry::read_global(cx);
registry.get_person(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<String> {
self.profile(cx).metadata().nip05
}
fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.public_key.to_bech32();
let item = ClipboardItem::new_string(bech32);
cx.write_to_clipboard(item);
self.set_copied(true, window, cx);
}
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
// Reset the copied state after a delay
if status {
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_copied(false, window, cx);
})
.ok();
})
.ok();
})
.detach();
}
}
}
impl Render for UserProfile {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let proxy = AppSettings::get_proxy_user_avatars(cx);
let profile = self.profile(cx);
let Ok(bech32) = profile.public_key().to_bech32();
let shared_bech32 = SharedString::new(bech32);
v_flex()
.gap_4()
.child(
v_flex()
.gap_3()
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(4.)))
.child(
v_flex()
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(profile.display_name()),
)
.when_some(self.address(cx), |this, address| {
this.child(
h_flex()
.justify_center()
.gap_1()
.text_xs()
.text_color(cx.theme().text_muted)
.child(address)
.when(self.verified, |this| {
this.child(
div()
.relative()
.text_color(cx.theme().text_accent)
.child(
Icon::new(IconName::CheckCircleFill)
.small()
.block(),
),
)
}),
)
}),
)
.when(!self.followed, |this| {
this.child(
div()
.flex_none()
.w_32()
.p_1()
.rounded_full()
.bg(cx.theme().elevated_surface_background)
.text_xs()
.font_semibold()
.child(SharedString::new(t!("profile.unknown"))),
)
}),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.block()
.text_color(cx.theme().text_muted)
.child("Public Key:"),
)
.child(
h_flex()
.gap_1()
.child(
div()
.p_2()
.h_9()
.rounded_md()
.bg(cx.theme().elevated_surface_background)
.truncate()
.text_ellipsis()
.line_clamp(1)
.child(shared_bech32),
)
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircleFill
} else {
IconName::Copy
}
})
.ghost()
.disabled(self.copied)
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy_pubkey(window, cx);
})),
),
),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("profile.label_bio"))),
)
.child(
div()
.p_2()
.rounded_md()
.bg(cx.theme().elevated_surface_background)
.child(
profile
.metadata()
.about
.unwrap_or(t!("profile.no_bio").to_string()),
),
),
)
}
}
use std::time::Duration;
use common::{nip05_verify, shorten_pubkey};
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement,
ParentElement, Render, SharedString, Styled, Task, Window,
};
use gpui_tokio::Tokio;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfileViewer> {
cx.new(|cx| ProfileViewer::new(public_key, window, cx))
}
#[derive(Debug)]
pub struct ProfileViewer {
profile: Person,
/// Follow status
followed: bool,
/// Verification status
verified: bool,
/// Copy status
copied: bool,
/// Async operations
_tasks: SmallVec<[Task<()>; 1]>,
}
impl ProfileViewer {
pub fn new(target: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&target, cx);
let mut tasks = smallvec![];
let check_follow: Task<Result<bool, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let contact_list = client.database().contacts_public_keys(public_key).await?;
Ok(contact_list.contains(&target))
});
let verify_nip05 = if let Some(address) = profile.metadata().nip05 {
Some(Tokio::spawn(cx, async move {
nip05_verify(target, &address).await.unwrap_or(false)
}))
} else {
None
};
tasks.push(
// Load user profile data
cx.spawn_in(window, async move |this, cx| {
let followed = check_follow.await.unwrap_or(false);
// Update the followed status
this.update(cx, |this, cx| {
this.followed = followed;
cx.notify();
})
.ok();
// Update the NIP05 verification status if user has NIP05 address
if let Some(task) = verify_nip05 {
if let Ok(verified) = task.await {
this.update(cx, |this, cx| {
this.verified = verified;
cx.notify();
})
.ok();
}
}
}),
);
Self {
profile,
followed: false,
verified: false,
copied: false,
_tasks: tasks,
}
}
fn address(&self, _cx: &Context<Self>) -> Option<String> {
self.profile.metadata().nip05
}
fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.profile.public_key().to_bech32();
let item = ClipboardItem::new_string(bech32);
cx.write_to_clipboard(item);
self.set_copied(true, window, cx);
}
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
if status {
self._tasks.push(
// Reset the copied state after a delay
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_copied(false, window, cx);
})
.ok();
})
.ok();
}),
);
}
}
}
impl Render for ProfileViewer {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let bech32 = shorten_pubkey(self.profile.public_key(), 16);
let shared_bech32 = SharedString::from(bech32);
v_flex()
.gap_4()
.text_sm()
.child(
v_flex()
.gap_3()
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(self.profile.avatar()).size(rems(4.)))
.child(
v_flex()
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(self.profile.name()),
)
.when_some(self.address(cx), |this, address| {
this.child(
h_flex()
.justify_center()
.gap_1()
.text_xs()
.text_color(cx.theme().text_muted)
.child(address)
.when(self.verified, |this| {
this.child(
div()
.relative()
.text_color(cx.theme().text_accent)
.child(
Icon::new(IconName::CheckCircleFill)
.small()
.block(),
),
)
}),
)
}),
)
.when(!self.followed, |this| {
this.child(
div()
.flex_none()
.w_32()
.p_1()
.rounded_full()
.bg(cx.theme().elevated_surface_background)
.text_xs()
.font_semibold()
.child(SharedString::from("Unknown contact")),
)
}),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Bio:")),
)
.child(
div()
.p_2()
.min_h_16()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(
self.profile
.metadata()
.about
.map(SharedString::from)
.unwrap_or(SharedString::from("No bio.")),
),
),
)
.child(div().my_1().h_px().w_full().bg(cx.theme().border))
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(SharedString::from("Public Key:")),
)
.child(
h_flex()
.gap_2()
.w_full()
.h_12()
.justify_center()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.text_sm()
.child(shared_bech32)
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircleFill
} else {
IconName::Copy
}
})
.xsmall()
.ghost()
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy_pubkey(window, cx);
})),
),
),
)
}
}

View File

@@ -1,258 +0,0 @@
use std::fs;
use std::time::Duration;
use dirs::document_dir;
use gpui::prelude::FluentBuilder;
use gpui::{
div, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement, Render,
SharedString, Styled, Window,
};
use i18n::{shared_t, t};
use identity::Identity;
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::{divider, h_flex, v_flex, ContextModal, Disableable, IconName, Sizable};
pub fn backup_button(keys: Keys) -> impl IntoElement {
div().child(
Button::new("backup")
.icon(IconName::Info)
.label(t!("new_account.backup_label"))
.danger()
.xsmall()
.rounded(ButtonRounded::Full)
.on_click(move |_, window, cx| {
let title = SharedString::new(t!("new_account.backup_label"));
let keys = keys.clone();
let view = cx.new(|cx| BackupKeys::new(&keys, window, cx));
let weak_view = view.downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
modal
.confirm()
.title(title.clone())
.child(view.clone())
.button_props(
ModalButtonProps::default()
.cancel_text(t!("new_account.backup_skip"))
.ok_text(t!("new_account.backup_download")),
)
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
this.download(window, cx);
})
.ok();
// true to close the modal
false
})
})
}),
)
}
pub struct BackupKeys {
password: Entity<InputState>,
pubkey_input: Entity<InputState>,
secret_input: Entity<InputState>,
error: Option<SharedString>,
copied: bool,
}
impl BackupKeys {
pub fn new(keys: &Keys, window: &mut Window, cx: &mut Context<'_, Self>) -> Self {
let Ok(npub) = keys.public_key.to_bech32();
let Ok(nsec) = keys.secret_key().to_bech32();
let password = cx.new(|cx| InputState::new(window, cx).masked(true));
let pubkey_input = cx.new(|cx| {
InputState::new(window, cx)
.disabled(true)
.default_value(npub)
});
let secret_input = cx.new(|cx| {
InputState::new(window, cx)
.disabled(true)
.default_value(nsec)
});
Self {
password,
pubkey_input,
secret_input,
error: None,
copied: false,
}
}
fn copy_secret(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let item = ClipboardItem::new_string(self.secret_input.read(cx).value().to_string());
cx.write_to_clipboard(item);
self.set_copied(true, window, cx);
}
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
// Reset the copied state after a delay
if status {
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_copied(false, window, cx);
})
.ok();
})
.ok();
})
.detach();
}
}
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
where
E: Into<SharedString>,
{
self.error = Some(error.into());
cx.notify();
// Clear the error message after a delay
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})
.ok();
})
.ok();
})
.detach();
}
fn download(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let document_dir = document_dir().expect("Failed to get document directory");
let password = self.password.read(cx).value().to_string();
if password.is_empty() {
self.set_error(t!("login.password_is_required"), window, cx);
return;
};
let path = cx.prompt_for_new_path(&document_dir);
let nsec = self.secret_input.read(cx).value().to_string();
cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(path.await.map_err(|e| e.into())) {
Ok(Ok(Some(path))) => {
cx.update(|window, cx| {
match fs::write(&path, nsec) {
Ok(_) => {
Identity::global(cx).update(cx, |this, cx| {
this.clear_need_backup(password, cx);
});
// Close the current modal
window.close_modal(cx);
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), window, cx);
})
.ok();
}
};
})
.ok();
}
_ => {
log::error!("Failed to save backup keys");
}
};
})
.detach();
}
}
impl Render for BackupKeys {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.text_sm()
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("new_account.backup_description")),
)
.child(
v_flex()
.gap_1()
.child(shared_t!("common.pubkey"))
.child(TextInput::new(&self.pubkey_input).small())
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(shared_t!("new_account.backup_pubkey_note")),
),
)
.child(divider(cx))
.child(
v_flex()
.gap_1()
.child(shared_t!("common.secret"))
.child(
h_flex()
.gap_1()
.child(TextInput::new(&self.secret_input).small())
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircleFill
} else {
IconName::Copy
}
})
.ghost()
.disabled(self.copied)
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy_secret(window, cx);
})),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(shared_t!("new_account.backup_secret_note")),
),
)
.child(divider(cx))
.child(
v_flex()
.gap_1()
.child(shared_t!("login.set_password"))
.child(TextInput::new(&self.password).small())
.when_some(self.error.as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
)
}
}

View File

@@ -1,942 +0,0 @@
use std::collections::{BTreeSet, HashMap};
use std::rc::Rc;
use std::sync::Arc;
use anyhow::anyhow;
use common::display::DisplayProfile;
use common::nip96::nip96_upload;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, list, px, red, rems, white, Action, AnyElement, App, AppContext, ClipboardItem,
Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement,
IntoElement, ListAlignment, ListState, MouseButton, ObjectFit, ParentElement,
PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement,
Styled, StyledImage, Subscription, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use registry::message::Message;
use registry::room::{Room, RoomKind, RoomSignal, SendError};
use registry::Registry;
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use smol::fs;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::emoji_picker::EmojiPicker;
use ui::input::{InputEvent, InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::notification::Notification;
use ui::popup_menu::PopupMenu;
use ui::text::RichText;
use ui::{
v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
};
mod subject;
const DUPLICATE_TIME_WINDOW: u64 = 10;
const MAX_RECENT_MESSAGES_TO_CHECK: usize = 5;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = chat, no_json)]
pub struct ChangeSubject(pub String);
pub fn init(room: Entity<Room>, window: &mut Window, cx: &mut App) -> Arc<Entity<Chat>> {
Arc::new(Chat::new(room, window, cx))
}
pub struct Chat {
// Panel
id: SharedString,
focus_handle: FocusHandle,
// Chat Room
room: Entity<Room>,
messages: Entity<BTreeSet<Message>>,
text_data: HashMap<EventId, RichText>,
list_state: ListState,
// New Message
input: Entity<InputState>,
replies_to: Entity<Option<Vec<Message>>>,
// Media Attachment
attaches: Entity<Option<Vec<Url>>>,
uploading: bool,
// System
image_cache: Entity<RetainAllImageCache>,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
}
impl Chat {
pub fn new(room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Self> {
let attaches = cx.new(|_| None);
let replies_to = cx.new(|_| None);
let messages = cx.new(|_| BTreeSet::new());
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("chat.placeholder"))
.multi_line()
.prevent_new_line_on_enter()
.rows(1)
.multi_line()
.auto_grow(1, 20)
.clean_on_escape()
});
cx.new(|cx| {
let mut subscriptions = smallvec![];
subscriptions.push(cx.subscribe_in(
&input,
window,
move |this: &mut Self, input, event, window, cx| {
match event {
InputEvent::PressEnter { .. } => {
this.send_message(window, cx);
}
InputEvent::Change(text) => {
this.mention_popup(text, input, cx);
}
_ => {}
};
},
));
subscriptions.push(cx.subscribe_in(
&room,
window,
move |this, _, signal, window, cx| {
match signal {
RoomSignal::NewMessage(event) => {
// Check if the incoming message is the same as the new message created by optimistic update
if this.prevent_duplicate_message(event, cx) {
return;
}
let old_len = this.messages.read(cx).len();
let message = event.to_owned();
cx.update_entity(&this.messages, |this, cx| {
this.insert(message);
cx.notify();
});
this.list_state.splice(old_len..old_len, 1);
}
RoomSignal::Refresh => {
this.load_messages(window, cx);
}
};
},
));
// Initialize list state
// [item_count] always equal to 1 at the beginning
let list_state = ListState::new(1, ListAlignment::Bottom, px(1024.));
Self {
id: room.read(cx).id.to_string().into(),
image_cache: RetainAllImageCache::new(cx),
focus_handle: cx.focus_handle(),
uploading: false,
text_data: HashMap::new(),
room,
messages,
list_state,
input,
replies_to,
attaches,
subscriptions,
}
})
}
/// Load all messages belonging to this room
pub(crate) fn load_messages(&self, window: &mut Window, cx: &mut Context<Self>) {
let room = self.room.read(cx);
let load_messages = room.load_messages(cx);
cx.spawn_in(window, async move |this, cx| {
match load_messages.await {
Ok(messages) => {
this.update(cx, |this, cx| {
let old_len = this.messages.read(cx).len();
let new_len = messages.len();
// Extend the messages list with the new events
this.messages.update(cx, |this, cx| {
this.extend(messages);
cx.notify();
});
// Update list state with the new messages
this.list_state.splice(old_len..old_len, new_len);
cx.notify();
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
}
})
.detach();
}
fn mention_popup(&mut self, _text: &str, _input: &Entity<InputState>, _cx: &mut Context<Self>) {
// TODO: open mention popup at current cursor position
}
/// Get user input content and merged all attachments
fn input_content(&self, cx: &Context<Self>) -> String {
let mut content = self.input.read(cx).value().trim().to_string();
// Get all attaches and merge its with message
if let Some(attaches) = self.attaches.read(cx).as_ref() {
if !attaches.is_empty() {
content = format!(
"{}\n{}",
content,
attaches
.iter()
.map(|url| url.to_string())
.collect_vec()
.join("\n")
)
}
}
content
}
// TODO: find a better way to prevent duplicate messages during optimistic updates
fn prevent_duplicate_message(&self, new_msg: &Message, cx: &Context<Self>) -> bool {
let Some(identity) = Identity::read_global(cx).public_key() else {
return false;
};
if new_msg.author != identity {
return false;
}
let messages = self.messages.read(cx);
let min_timestamp = new_msg
.created_at
.as_u64()
.saturating_sub(DUPLICATE_TIME_WINDOW);
messages
.iter()
.rev()
.take(MAX_RECENT_MESSAGES_TO_CHECK)
.filter(|m| m.author == identity)
.any(|existing| {
// Check if messages are within the time window
(existing.created_at.as_u64() >= min_timestamp) &&
// Compare content and author
(existing.content == new_msg.content) &&
(existing.author == new_msg.author)
})
}
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Return if user is not logged in
let Some(identity) = Identity::read_global(cx).public_key() else {
// window.push_notification("Login is required", cx);
return;
};
// Get the message which includes all attachments
let content = self.input_content(cx);
// Get the backup setting
let backup = AppSettings::get_backup_messages(cx);
// Return if message is empty
if content.trim().is_empty() {
window.push_notification(t!("chat.empty_message_error"), cx);
return;
}
// Temporary disable input
self.input.update(cx, |this, cx| {
this.set_loading(true, cx);
this.set_disabled(true, cx);
});
// Get replies_to if it's present
let replies = self.replies_to.read(cx).as_ref();
// Get the current room entity
let room = self.room.read(cx);
// Create a temporary message for optimistic update
let temp_message = room.create_temp_message(identity, &content, replies);
// Create a task for sending the message in the background
let send_message = room.send_in_background(&content, replies, backup, cx);
if let Some(message) = temp_message {
let id = message.id;
// Optimistically update message list
self.insert_message(message, cx);
// Remove all replies
self.remove_all_replies(cx);
// Reset the input state
self.input.update(cx, |this, cx| {
this.set_loading(false, cx);
this.set_disabled(false, cx);
this.set_value("", window, cx);
});
// Continue sending the message in the background
cx.spawn_in(window, async move |this, cx| {
if let Ok(reports) = send_message.await {
if !reports.is_empty() {
this.update(cx, |this, cx| {
this.room.update(cx, |this, cx| {
if this.kind != RoomKind::Ongoing {
this.kind = RoomKind::Ongoing;
cx.notify();
}
});
this.messages.update(cx, |this, cx| {
if let Some(mut msg) = this.iter().find(|msg| msg.id == id).cloned()
{
msg.errors = Some(reports);
cx.notify();
}
});
})
.ok();
}
}
})
.detach();
}
}
fn insert_message(&self, message: Message, cx: &mut Context<Self>) {
let old_len = self.messages.read(cx).len();
cx.update_entity(&self.messages, |this, cx| {
this.insert(message);
cx.notify();
});
self.list_state.splice(old_len..old_len, 1);
}
fn scroll_to(&self, id: EventId, cx: &Context<Self>) {
if let Some(ix) = self.messages.read(cx).iter().position(|m| m.id == id) {
self.list_state.scroll_to_reveal_item(ix);
}
}
fn copy_message(&self, ix: usize, cx: &Context<Self>) {
let Some(item) = self
.messages
.read(cx)
.iter()
.nth(ix)
.map(|m| ClipboardItem::new_string(m.content.to_string()))
else {
return;
};
cx.write_to_clipboard(item);
}
fn reply_to(&mut self, ix: usize, cx: &mut Context<Self>) {
let Some(message) = self.messages.read(cx).iter().nth(ix).map(|m| m.to_owned()) else {
return;
};
self.replies_to.update(cx, |this, cx| {
if let Some(replies) = this {
replies.push(message);
} else {
*this = Some(vec![message])
}
cx.notify();
});
}
fn remove_reply(&mut self, id: EventId, cx: &mut Context<Self>) {
self.replies_to.update(cx, |this, cx| {
if let Some(replies) = this {
if let Some(ix) = replies.iter().position(|m| m.id == id) {
replies.remove(ix);
cx.notify();
}
}
});
}
fn remove_all_replies(&mut self, cx: &mut Context<Self>) {
self.replies_to.update(cx, |this, cx| {
*this = None;
cx.notify();
});
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.uploading {
return;
}
// Block the upload button to until current task is resolved
self.uploading(true, cx);
// Get the user's configured NIP96 server
let nip96_server = AppSettings::get_media_server(cx);
// Open native file dialog
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
let task = Tokio::spawn(cx, async move {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
if let Some(path) = paths.pop() {
let file = fs::read(path).await?;
let url = nip96_upload(nostr_client(), &nip96_server, file).await?;
Ok(url)
} else {
Err(anyhow!("Path not found"))
}
}
Ok(None) => Err(anyhow!("User cancelled")),
Err(e) => Err(anyhow!("File dialog error: {e}")),
}
});
cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(task.await.map_err(|e| e.into())) {
Ok(Ok(url)) => {
this.update(cx, |this, cx| {
this.add_attachment(url, cx);
})
.ok();
}
Ok(Err(e)) => {
log::warn!("User cancelled: {e}");
this.update(cx, |this, cx| {
this.uploading(false, cx);
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
window.push_notification(e.to_string(), cx);
this.uploading(false, cx);
})
.ok();
})
.ok();
}
}
})
.detach();
}
fn add_attachment(&mut self, url: Url, cx: &mut Context<Self>) {
self.attaches.update(cx, |this, cx| {
if let Some(model) = this.as_mut() {
model.push(url);
} else {
*this = Some(vec![url]);
}
cx.notify();
});
self.uploading(false, cx);
}
fn remove_attachment(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) {
self.attaches.update(cx, |model, cx| {
if let Some(urls) = model.as_mut() {
if let Some(ix) = urls.iter().position(|x| x == url) {
urls.remove(ix);
cx.notify();
}
}
});
}
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.uploading = status;
cx.notify();
}
fn render_attach(&mut self, url: &Url, cx: &Context<Self>) -> impl IntoElement {
let url = url.clone();
let path: SharedString = url.to_string().into();
div()
.id("")
.relative()
.w_16()
.child(
img(path.clone())
.size_16()
.shadow_lg()
.rounded(cx.theme().radius)
.object_fit(ObjectFit::ScaleDown),
)
.child(
div()
.absolute()
.top_neg_2()
.right_neg_2()
.size_4()
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(red())
.child(Icon::new(IconName::Close).size_2().text_color(white())),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.remove_attachment(&url, window, cx);
}))
}
fn render_reply_to(&mut self, message: &Message, cx: &Context<Self>) -> impl IntoElement {
let registry = Registry::read_global(cx);
let profile = registry.get_person(&message.author, cx);
div()
.w_full()
.pl_2()
.border_l_2()
.border_color(cx.theme().element_active)
.child(
div()
.flex()
.items_center()
.justify_between()
.child(
div()
.flex()
.items_baseline()
.gap_1()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("chat.replying_to_label")))
.child(
div()
.text_color(cx.theme().text_accent)
.child(profile.display_name()),
),
)
.child(
Button::new("remove-reply")
.icon(IconName::Close)
.xsmall()
.ghost()
.on_click({
let id = message.id;
cx.listener(move |this, _, _, cx| {
this.remove_reply(id, cx);
})
}),
),
)
.child(
div()
.w_full()
.text_sm()
.text_ellipsis()
.line_clamp(1)
.child(message.content.clone()),
)
}
fn render_message(
&mut self,
ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let Some(message) = self.messages.read(cx).iter().nth(ix) else {
return div().id(ix);
};
let proxy = AppSettings::get_proxy_user_avatars(cx);
let hide_avatar = AppSettings::get_hide_user_avatars(cx);
let registry = Registry::read_global(cx);
let author = registry.get_person(&message.author, cx);
let texts = self
.text_data
.entry(message.id)
.or_insert_with(|| RichText::new(&message.content, cx));
div()
.id(ix)
.group("")
.relative()
.w_full()
.py_1()
.px_3()
.child(
div()
.flex()
.gap_3()
.when(!hide_avatar, |this| {
this.child(Avatar::new(author.avatar_url(proxy)).size(rems(2.)))
})
.child(
div()
.flex_1()
.flex()
.flex_col()
.flex_initial()
.overflow_hidden()
.child(
div()
.flex()
.items_baseline()
.gap_2()
.text_sm()
.child(
div()
.font_semibold()
.text_color(cx.theme().text)
.child(author.display_name()),
)
.child(
div()
.text_color(cx.theme().text_placeholder)
.child(message.ago()),
),
)
.when_some(message.replies_to.as_ref(), |this, replies| {
this.w_full().children({
let mut items = Vec::with_capacity(replies.len());
let messages = self.messages.read(cx);
for (ix, id) in replies.iter().cloned().enumerate() {
let Some(message) = messages.iter().find(|m| m.id == id)
else {
continue;
};
items.push(
div()
.id(ix)
.w_full()
.px_2()
.border_l_2()
.border_color(cx.theme().element_selected)
.text_sm()
.child(
div()
.text_color(cx.theme().text_accent)
.child(author.display_name()),
)
.child(
div()
.w_full()
.text_ellipsis()
.line_clamp(1)
.child(message.content.clone()),
)
.hover(|this| {
this.bg(cx.theme().elevated_surface_background)
})
.on_click(cx.listener(move |this, _, _, cx| {
this.scroll_to(id, cx)
})),
);
}
items
})
})
.child(texts.element(ix.into(), window, cx))
.when_some(message.errors.as_ref(), |this, errors| {
this.child(self.render_message_errors(errors, cx))
}),
),
)
.child(self.render_border(cx))
.child(self.render_actions(ix, cx))
.on_mouse_down(
MouseButton::Middle,
cx.listener(move |this, _event, _window, cx| {
this.copy_message(ix, cx);
}),
)
.on_double_click(cx.listener({
move |this, _event, _window, cx| {
this.reply_to(ix, cx);
}
}))
.hover(|this| this.bg(cx.theme().surface_background))
}
fn render_message_errors(&self, errors: &[SendError], _cx: &Context<Self>) -> impl IntoElement {
let errors = Rc::new(errors.to_owned());
div()
.id("")
.flex()
.items_center()
.gap_1()
.text_color(gpui::red())
.text_xs()
.italic()
.child(Icon::new(IconName::Info).small())
.child(SharedString::new(t!("chat.send_fail")))
.on_click(move |_, window, cx| {
let errors = Rc::clone(&errors);
window.open_modal(cx, move |this, _window, cx| {
this.title(SharedString::new(t!("chat.logs_title"))).child(
div()
.pb_4()
.flex()
.flex_col()
.gap_2()
.children(errors.iter().map(|error| {
div()
.text_sm()
.child(
div()
.flex()
.items_baseline()
.gap_1()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("chat.send_to_label")))
.child(error.profile.display_name()),
)
.child(error.message.clone())
})),
)
});
})
}
fn render_border(&self, cx: &Context<Self>) -> impl IntoElement {
div()
.group_hover("", |this| this.bg(cx.theme().element_active))
.absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(cx.theme().border_transparent)
}
fn render_actions(&self, ix: usize, cx: &Context<Self>) -> impl IntoElement {
div()
.group_hover("", |this| this.visible())
.invisible()
.absolute()
.right_4()
.top_neg_2()
.shadow_sm()
.rounded_md()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.p_0p5()
.flex()
.gap_1()
.children({
vec![
Button::new("reply")
.icon(IconName::Reply)
.tooltip(t!("chat.reply_button"))
.small()
.ghost()
.on_click(cx.listener(move |this, _event, _window, cx| {
this.reply_to(ix, cx);
})),
Button::new("copy")
.icon(IconName::Copy)
.tooltip(t!("chat.copy_message_button"))
.small()
.ghost()
.on_click(cx.listener(move |this, _event, _window, cx| {
this.copy_message(ix, cx);
})),
]
})
}
}
impl Panel for Chat {
fn panel_id(&self) -> SharedString {
self.id.clone()
}
fn title(&self, cx: &App) -> AnyElement {
self.room.read_with(cx, |this, cx| {
let proxy = AppSettings::get_proxy_user_avatars(cx);
let label = this.display_name(cx);
let url = this.display_image(proxy, cx);
div()
.flex()
.items_center()
.gap_1p5()
.child(Avatar::new(url).size(rems(1.25)))
.child(label)
.into_any()
})
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, cx: &App) -> Vec<Button> {
let room = self.room.downgrade();
let subject = self
.room
.read(cx)
.subject
.as_ref()
.map(|subject| subject.to_string());
let button = Button::new("subject")
.icon(IconName::EditFill)
.tooltip(t!("chat.change_subject_button"))
.on_click(move |_, window, cx| {
let view = subject::init(subject.clone(), window, cx);
let room = room.clone();
let weak_view = view.downgrade();
window.open_modal(cx, move |this, _window, _cx| {
let room = room.clone();
let weak_view = weak_view.clone();
this.confirm()
.title(SharedString::new(t!("chat.change_subject_modal_title")))
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.change")))
.on_ok(move |_, _window, cx| {
if let Ok(subject) =
weak_view.read_with(cx, |this, cx| this.new_subject(cx))
{
room.update(cx, |this, cx| {
this.subject = Some(subject);
cx.notify();
})
.ok();
}
// true to close the modal
true
})
});
});
vec![button]
}
}
impl EventEmitter<PanelEvent> for Chat {}
impl Focusable for Chat {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Chat {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let entity = cx.entity();
v_flex()
.image_cache(self.image_cache.clone())
.size_full()
.child(
list(self.list_state.clone(), move |ix, window, cx| {
entity.update(cx, |this, cx| {
this.render_message(ix, window, cx).into_any_element()
})
})
.flex_1(),
)
.child(
div()
.flex_shrink_0()
.w_full()
.relative()
.px_3()
.py_2()
.child(
div()
.flex()
.flex_col()
.when_some(self.attaches.read(cx).as_ref(), |this, urls| {
this.gap_1p5()
.children(urls.iter().map(|url| self.render_attach(url, cx)))
})
.when_some(self.replies_to.read(cx).as_ref(), |this, messages| {
this.gap_1p5().children({
let mut items = vec![];
for message in messages.iter() {
items.push(self.render_reply_to(message, cx));
}
items
})
})
.child(
div()
.w_full()
.flex()
.items_end()
.gap_2p5()
.child(
div()
.flex()
.items_center()
.gap_1()
.text_color(cx.theme().text_muted)
.child(
Button::new("upload")
.icon(IconName::Upload)
.ghost()
.large()
.disabled(self.uploading)
.loading(self.uploading)
.on_click(cx.listener(
move |this, _, window, cx| {
this.upload(window, cx);
},
)),
)
.child(
EmojiPicker::new(self.input.downgrade())
.icon(IconName::EmojiFill)
.large(),
),
)
.child(TextInput::new(&self.input)),
),
),
)
}
}

View File

@@ -1,55 +0,0 @@
use gpui::{
div, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Window,
};
use i18n::t;
use theme::ActiveTheme;
use ui::input::{InputState, TextInput};
use ui::{v_flex, Sizable};
pub fn init(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Subject> {
Subject::new(subject, window, cx)
}
pub struct Subject {
input: Entity<InputState>,
}
impl Subject {
pub fn new(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Self> {
let input = cx.new(|cx| {
let mut this = InputState::new(window, cx).placeholder(t!("subject.placeholder"));
if let Some(text) = subject.as_ref() {
this.set_value(text, window, cx);
}
this
});
cx.new(|_| Self { input })
}
pub fn new_subject(&self, cx: &App) -> SharedString {
self.input.read(cx).value().clone()
}
}
impl Render for Subject {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_1()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("subject.title"))),
)
.child(TextInput::new(&self.input).small())
.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(SharedString::new(t!("subject.help_text"))),
)
}
}

View File

@@ -2,28 +2,24 @@ use std::ops::Range;
use std::time::Duration;
use anyhow::{anyhow, Error};
use common::display::{DisplayProfile, TextUtils};
use common::nip05::nip05_profile;
use global::constants::BOOTSTRAP_RELAYS;
use global::nostr_client;
use chat::{ChatRegistry, Room};
use common::{nip05_profile, TextUtils, BOOTSTRAP_RELAYS};
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, relative, rems, uniform_list, AppContext, Context, Entity, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
Subscription, Task, Window,
div, px, relative, rems, uniform_list, App, AppContext, Context, Entity, InteractiveElement,
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString,
StatefulInteractiveElement, Styled, Subscription, Task, Window,
};
use i18n::t;
use itertools::Itertools;
use gpui_tokio::Tokio;
use nostr_sdk::prelude::*;
use registry::room::{Room, RoomKind};
use registry::Registry;
use settings::AppSettings;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use smol::Timer;
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::notification::Notification;
use ui::{h_flex, v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
@@ -34,22 +30,46 @@ pub fn compose_button() -> impl IntoElement {
.ghost_alt()
.cta()
.small()
.rounded(ButtonRounded::Full)
.rounded()
.on_click(move |_, window, cx| {
let compose = cx.new(|cx| Compose::new(window, cx));
let title = SharedString::new(t!("sidebar.direct_messages"));
let weak_view = compose.downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
modal.title(title.clone()).child(compose.clone())
window.open_modal(cx, move |modal, _window, cx| {
let weak_view = weak_view.clone();
let label = if compose.read(cx).selected(cx).len() > 1 {
SharedString::from("Create Group DM")
} else {
SharedString::from("Create DM")
};
modal
.alert()
.overlay_closable(true)
.keyboard(true)
.show_close(true)
.button_props(ModalButtonProps::default().ok_text(label))
.title(SharedString::from("Direct Messages"))
.child(compose.clone())
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
this.submit(window, cx);
})
.ok();
// false to prevent the modal from closing
false
})
})
}),
)
}
#[derive(Debug)]
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Contact {
public_key: PublicKey,
select: bool,
selected: bool,
}
impl AsRef<PublicKey> for Contact {
@@ -62,12 +82,12 @@ impl Contact {
pub fn new(public_key: PublicKey) -> Self {
Self {
public_key,
select: false,
selected: false,
}
}
pub fn select(mut self) -> Self {
self.select = true;
pub fn selected(mut self) -> Self {
self.selected = true;
self
}
}
@@ -75,85 +95,106 @@ impl Contact {
pub struct Compose {
/// Input for the room's subject
title_input: Entity<InputState>,
/// Input for the room's members
user_input: Entity<InputState>,
/// The current user's contacts
contacts: Vec<Entity<Contact>>,
/// Input error message
/// User's contacts
contacts: Entity<Vec<Contact>>,
/// Error message
error_message: Entity<Option<SharedString>>,
adding: bool,
submitting: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
image_cache: Entity<RetainAllImageCache>,
_subscriptions: SmallVec<[Subscription; 2]>,
_tasks: SmallVec<[Task<()>; 1]>,
}
impl Compose {
pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let contacts = cx.new(|_| vec![]);
let error_message = cx.new(|_| None);
let user_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("compose.placeholder_npub")));
cx.new(|cx| InputState::new(window, cx).placeholder("npub or nprofile..."));
let title_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("compose.placeholder_title")));
cx.new(|cx| InputState::new(window, cx).placeholder("Family...(Optional)"));
let error_message = cx.new(|_| None);
let mut subscriptions = smallvec![];
// Handle Enter event for user input
subscriptions.push(cx.subscribe_in(
&user_input,
window,
move |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add_and_select_contact(window, cx)
};
},
));
let mut tasks = smallvec![];
let get_contacts: Task<Result<Vec<Contact>, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let profiles = client.database().contacts(public_key).await?;
let contacts = profiles
let contacts: Vec<Contact> = profiles
.into_iter()
.map(|profile| Contact::new(profile.public_key()))
.collect_vec();
.collect();
Ok(contacts)
});
cx.spawn_in(window, async move |this, cx| {
match get_contacts.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.extend_contacts(contacts, cx);
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
};
})
.detach();
tasks.push(
// Load all contacts
cx.spawn_in(window, async move |this, cx| {
match get_contacts.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.extend_contacts(contacts, cx);
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
};
}),
);
subscriptions.push(
// Clear the image cache when sidebar is closed
cx.on_release_in(window, move |this, window, cx| {
this.image_cache.update(cx, |this, cx| {
this.clear(window, cx);
})
}),
);
subscriptions.push(
// Handle Enter event for user input
cx.subscribe_in(
&user_input,
window,
move |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add_and_select_contact(window, cx)
};
},
),
);
Self {
adding: false,
submitting: false,
contacts: vec![],
title_input,
user_input,
error_message,
subscriptions,
contacts,
image_cache: RetainAllImageCache::new(cx),
_subscriptions: subscriptions,
_tasks: tasks,
}
}
async fn request_metadata(client: &Client, public_key: PublicKey) -> Result<(), Error> {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList];
let kinds = vec![Kind::Metadata, Kind::ContactList];
let filter = Filter::new().author(public_key).kinds(kinds).limit(10);
client
@@ -163,100 +204,103 @@ impl Compose {
Ok(())
}
pub fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let public_keys: Vec<PublicKey> = self.selected(cx);
if public_keys.is_empty() {
self.set_error(Some(t!("compose.receiver_required").into()), cx);
return;
};
// Show loading spinner
self.set_submitting(true, cx);
// Convert selected pubkeys into Nostr tags
let mut tag_list: Vec<Tag> = public_keys.iter().map(|pk| Tag::public_key(*pk)).collect();
// Add subject if it is present
if !self.title_input.read(cx).value().is_empty() {
tag_list.push(Tag::custom(
TagKind::Subject,
vec![self.title_input.read(cx).value().to_string()],
));
}
let event: Task<Result<Room, Error>> = cx.background_spawn(async move {
let signer = nostr_client().signer().await?;
let public_key = signer.get_public_key().await?;
let room = EventBuilder::private_msg_rumor(public_keys[0], "")
.tags(Tags::from_list(tag_list))
.build(public_key)
.sign(&Keys::generate())
.await
.map(|event| Room::new(&event).kind(RoomKind::Ongoing))?;
Ok(room)
});
cx.spawn_in(window, async move |this, cx| {
match event.await {
Ok(room) => {
cx.update(|window, cx| {
let registry = Registry::global(cx);
// Reset local state
this.update(cx, |this, cx| {
this.set_submitting(false, cx);
})
.ok();
// Create and insert the new room into the registry
registry.update(cx, |this, cx| {
this.push_room(cx.new(|_| room), cx);
});
// Close the current modal
window.close_modal(cx);
})
.ok();
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(Some(e.to_string().into()), cx);
})
.ok();
}
};
})
.detach();
}
fn extend_contacts<I>(&mut self, contacts: I, cx: &mut Context<Self>)
where
I: IntoIterator<Item = Contact>,
{
self.contacts
.extend(contacts.into_iter().map(|contact| cx.new(|_| contact)));
cx.notify();
self.contacts.update(cx, |this, cx| {
this.extend(contacts);
cx.notify();
});
}
fn push_contact(&mut self, contact: Contact, cx: &mut Context<Self>) {
if !self
.contacts
.iter()
.any(|e| e.read(cx).public_key == contact.public_key)
{
self.contacts.insert(0, cx.new(|_| contact));
cx.notify();
fn push_contact(&mut self, contact: Contact, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let pk = contact.public_key;
if !self.contacts.read(cx).iter().any(|c| c.public_key == pk) {
self._tasks.push(cx.background_spawn(async move {
Self::request_metadata(&client, pk).await.ok();
}));
cx.defer_in(window, |this, window, cx| {
this.contacts.update(cx, |this, cx| {
this.insert(0, contact);
cx.notify();
});
this.user_input.update(cx, |this, cx| {
this.set_value("", window, cx);
this.set_loading(false, cx);
});
});
} else {
self.set_error(Some(t!("compose.contact_existed").into()), cx);
self.set_error("Contact already added", cx);
}
}
fn selected(&self, cx: &Context<Self>) -> Vec<PublicKey> {
fn select_contact(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
self.contacts.update(cx, |this, cx| {
if let Some(contact) = this.iter_mut().find(|c| c.public_key == public_key) {
contact.selected = true;
}
cx.notify();
});
}
fn add_and_select_contact(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let content = self.user_input.read(cx).value().to_string();
// Show loading indicator in the input
self.user_input.update(cx, |this, cx| {
this.set_loading(true, cx);
});
if let Ok(public_key) = content.to_public_key() {
let contact = Contact::new(public_key).selected();
self.push_contact(contact, window, cx);
} else if content.contains("@") {
let task = Tokio::spawn(cx, async move {
if let Ok(profile) = nip05_profile(&content).await {
let public_key = profile.public_key;
let contact = Contact::new(public_key).selected();
Ok(contact)
} else {
Err(anyhow!("Not found"))
}
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(Ok(contact)) => {
this.update_in(cx, |this, window, cx| {
this.push_contact(contact, window, cx);
})
.ok();
}
Ok(Err(e)) => {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), cx);
})
.ok();
}
Err(e) => {
log::error!("Tokio error: {e}");
}
};
})
.detach();
}
}
fn selected(&self, cx: &App) -> Vec<PublicKey> {
self.contacts
.read(cx)
.iter()
.filter_map(|contact| {
if contact.read(cx).select {
Some(contact.read(cx).public_key)
if contact.selected {
Some(contact.public_key)
} else {
None
}
@@ -264,84 +308,29 @@ impl Compose {
.collect()
}
fn add_and_select_contact(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let content = self.user_input.read(cx).value().to_string();
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let chat = ChatRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let public_key = nostr.read(cx).identity().read(cx).public_key();
// Prevent multiple requests
self.set_adding(true, cx);
let receivers: Vec<PublicKey> = self.selected(cx);
let subject_input = self.title_input.read(cx).value();
let subject = (!subject_input.is_empty()).then(|| subject_input.to_string());
// Show loading indicator in the input
self.user_input.update(cx, |this, cx| {
this.set_loading(true, cx);
});
let task: Task<Result<Contact, Error>> = if content.contains("@") {
cx.background_spawn(async move {
let (tx, rx) = oneshot::channel::<Option<Nip05Profile>>();
nostr_sdk::async_utility::task::spawn(async move {
let profile = nip05_profile(&content).await.ok();
tx.send(profile).ok();
});
if let Ok(Some(profile)) = rx.await {
let client = nostr_client();
let public_key = profile.public_key;
let contact = Contact::new(public_key).select();
Self::request_metadata(client, public_key).await?;
Ok(contact)
} else {
Err(anyhow!(t!("common.not_found")))
}
})
} else if let Ok(public_key) = content.to_public_key() {
cx.background_spawn(async move {
let client = nostr_client();
let contact = Contact::new(public_key).select();
Self::request_metadata(client, public_key).await?;
Ok(contact)
})
} else {
self.set_error(Some(t!("common.pubkey_invalid").into()), cx);
if !self.user_input.read(cx).value().is_empty() {
self.add_and_select_contact(window, cx);
return;
};
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(contact) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.push_contact(contact, cx);
this.set_adding(false, cx);
this.user_input.update(cx, |this, cx| {
this.set_value("", window, cx);
this.set_loading(false, cx);
});
})
.ok();
})
.ok();
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(Some(e.to_string().into()), cx);
})
.ok();
}
};
})
.detach();
chat.update(cx, |this, cx| {
let room = cx.new(|_| Room::new(subject, public_key, receivers));
this.emit_room(room.downgrade(), cx);
});
window.close_modal(cx);
}
fn set_error(&mut self, error: impl Into<Option<SharedString>>, cx: &mut Context<Self>) {
if self.adding {
self.set_adding(false, cx);
}
fn set_error(&mut self, error: impl Into<SharedString>, cx: &mut Context<Self>) {
// Unlock the user input
self.user_input.update(cx, |this, cx| {
this.set_loading(false, cx);
@@ -349,63 +338,53 @@ impl Compose {
// Update error message
self.error_message.update(cx, |this, cx| {
*this = error.into();
*this = Some(error.into());
cx.notify();
});
// Dismiss error after 2 seconds
cx.spawn(async move |this, cx| {
Timer::after(Duration::from_secs(2)).await;
cx.background_executor().timer(Duration::from_secs(2)).await;
this.update(cx, |this, cx| {
this.set_error(None, cx);
this.error_message.update(cx, |this, cx| {
*this = None;
cx.notify();
});
})
.ok();
})
.detach();
}
fn set_adding(&mut self, status: bool, cx: &mut Context<Self>) {
self.adding = status;
cx.notify();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.submitting = status;
cx.notify();
}
fn list_items(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
let proxy = AppSettings::get_proxy_user_avatars(cx);
let registry = Registry::read_global(cx);
let mut items = Vec::with_capacity(self.contacts.len());
let persons = PersonRegistry::global(cx);
let mut items = Vec::with_capacity(self.contacts.read(cx).len());
for ix in range {
let Some(entity) = self.contacts.get(ix).cloned() else {
let Some(contact) = self.contacts.read(cx).get(ix) else {
continue;
};
let public_key = entity.read(cx).as_ref();
let profile = registry.get_person(public_key, cx);
let selected = entity.read(cx).select;
let public_key = contact.public_key;
let profile = persons.read(cx).get(&public_key, cx);
items.push(
h_flex()
.id(ix)
.px_1()
.h_9()
.px_2()
.h_11()
.w_full()
.justify_between()
.rounded(cx.theme().radius)
.child(
div()
.flex()
.items_center()
h_flex()
.gap_1p5()
.text_sm()
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(1.75)))
.child(profile.display_name()),
.child(Avatar::new(profile.avatar()).size(rems(1.75)))
.child(profile.name()),
)
.when(selected, |this| {
.when(contact.selected, |this| {
this.child(
Icon::new(IconName::CheckCircleFill)
.small()
@@ -413,11 +392,8 @@ impl Compose {
)
})
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.on_click(cx.listener(move |_this, _event, _window, cx| {
entity.update(cx, |this, cx| {
this.select = !this.select;
cx.notify();
});
.on_click(cx.listener(move |this, _, _window, cx| {
this.select_contact(public_key, cx);
})),
);
}
@@ -428,24 +404,18 @@ impl Compose {
impl Render for Compose {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let label = if self.submitting {
t!("compose.creating_dm_button")
} else if self.selected(cx).len() > 1 {
t!("compose.create_group_dm_button")
} else {
t!("compose.create_dm_button")
};
let error = self.error_message.read(cx).as_ref();
let loading = self.user_input.read(cx).loading;
let contacts = self.contacts.read(cx);
v_flex()
.mb_4()
.image_cache(self.image_cache.clone())
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("compose.description"))),
.child(SharedString::from("Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).")),
)
.when_some(error, |this, msg| {
this.child(
@@ -466,13 +436,13 @@ impl Render for Compose {
div()
.text_sm()
.font_semibold()
.child(SharedString::new(t!("compose.subject_label"))),
.child(SharedString::from("Subject:")),
)
.child(TextInput::new(&self.title_input).small().appearance(false)),
)
.child(
v_flex()
.my_1()
.pt_1()
.gap_2()
.child(
v_flex()
@@ -481,22 +451,18 @@ impl Render for Compose {
div()
.text_sm()
.font_semibold()
.child(SharedString::new(t!("compose.to_label"))),
.child(SharedString::from("To:")),
)
.child(
h_flex()
.gap_1()
.child(
TextInput::new(&self.user_input)
.small()
.disabled(self.adding),
)
.child(
TextInput::new(&self.user_input)
.small()
.disabled(loading)
.suffix(
Button::new("add")
.icon(IconName::PlusCircleFill)
.ghost()
.loading(self.adding)
.disabled(self.adding)
.transparent()
.small()
.disabled(loading)
.on_click(cx.listener(move |this, _, window, cx| {
this.add_and_select_contact(window, cx);
})),
@@ -504,7 +470,7 @@ impl Render for Compose {
),
)
.map(|this| {
if self.contacts.is_empty() {
if contacts.is_empty() {
this.child(
v_flex()
.h_24()
@@ -512,48 +478,32 @@ impl Render for Compose {
.items_center()
.justify_center()
.text_center()
.text_xs()
.child(
div()
.text_xs()
.font_semibold()
.line_height(relative(1.2))
.child(SharedString::new(t!(
"compose.no_contacts_message"
))),
.child(SharedString::from("No contacts")),
)
.child(
div().text_xs().text_color(cx.theme().text_muted).child(
SharedString::new(t!(
"compose.no_contacts_description"
)),
),
div()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Your recently contacts will appear here.")),
),
)
} else {
this.child(
uniform_list(
"contacts",
self.contacts.len(),
contacts.len(),
cx.processor(move |this, range, _window, cx| {
this.list_items(range, cx)
}),
)
.min_h(px(300.)),
.h(px(300.)),
)
}
}),
)
.child(
Button::new("create_dm_btn")
.label(label)
.primary()
.small()
.w_full()
.loading(self.submitting)
.disabled(self.submitting || self.adding)
.on_click(cx.listener(move |this, _event, window, cx| {
this.submit(window, cx);
})),
)
}
}

View File

@@ -1,280 +0,0 @@
use std::str::FromStr;
use std::time::Duration;
use common::nip96::nip96_upload;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, App, AppContext, Context, Entity, Flatten, IntoElement, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Task, Window,
};
use i18n::t;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::{v_flex, Disableable, IconName, Sizable};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<EditProfile> {
EditProfile::new(window, cx)
}
pub struct EditProfile {
profile: Option<Metadata>,
name_input: Entity<InputState>,
avatar_input: Entity<InputState>,
bio_input: Entity<InputState>,
website_input: Entity<InputState>,
is_loading: bool,
is_submitting: bool,
}
impl EditProfile {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let name_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("profile.placeholder_name")));
let avatar_input =
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.jpg"));
let website_input =
cx.new(|cx| InputState::new(window, cx).placeholder("https://your-website.com"));
let bio_input = cx.new(|cx| {
InputState::new(window, cx)
.multi_line()
.placeholder(t!("profile.placeholder_bio"))
});
cx.new(|cx| {
let this = Self {
name_input,
avatar_input,
bio_input,
website_input,
profile: None,
is_loading: false,
is_submitting: false,
};
let task: Task<Result<Option<Metadata>, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let metadata = client
.fetch_metadata(public_key, Duration::from_secs(2))
.await?;
Ok(metadata)
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some(metadata)) = task.await {
cx.update(|window, cx| {
this.update(cx, |this: &mut EditProfile, cx| {
this.avatar_input.update(cx, |this, cx| {
if let Some(avatar) = metadata.picture.as_ref() {
this.set_value(avatar, window, cx);
}
});
this.bio_input.update(cx, |this, cx| {
if let Some(bio) = metadata.about.as_ref() {
this.set_value(bio, window, cx);
}
});
this.name_input.update(cx, |this, cx| {
if let Some(display_name) = metadata.display_name.as_ref() {
this.set_value(display_name, window, cx);
}
});
this.website_input.update(cx, |this, cx| {
if let Some(website) = metadata.website.as_ref() {
this.set_value(website, window, cx);
}
});
this.profile = Some(metadata);
cx.notify();
})
.ok();
})
.ok();
}
})
.detach();
this
})
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nip96 = AppSettings::get_media_server(cx);
let avatar_input = self.avatar_input.downgrade();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
// Show loading spinner
self.set_loading(true, cx);
cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
let path = paths.pop().unwrap();
if let Ok(file_data) = fs::read(path).await {
let (tx, rx) = oneshot::channel::<Url>();
nostr_sdk::async_utility::task::spawn(async move {
if let Ok(url) = nip96_upload(nostr_client(), &nip96, file_data).await {
_ = tx.send(url);
}
});
if let Ok(url) = rx.await {
cx.update(|window, cx| {
// Stop loading spinner
this.update(cx, |this, cx| {
this.set_loading(false, cx);
})
.ok();
// Set avatar input
avatar_input
.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
})
.ok();
})
.ok();
}
}
}
Ok(None) => {
cx.update(|_, cx| {
// Stop loading spinner
this.update(cx, |this, cx| {
this.set_loading(false, cx);
})
.ok();
})
.ok();
}
Err(_) => {}
}
})
.detach();
}
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Option<Event>, Error>> {
let avatar = self.avatar_input.read(cx).value().to_string();
let name = self.name_input.read(cx).value().to_string();
let bio = self.bio_input.read(cx).value().to_string();
let website = self.website_input.read(cx).value().to_string();
let old_metadata = if let Some(metadata) = self.profile.as_ref() {
metadata.clone()
} else {
Metadata::default()
};
let mut new_metadata = old_metadata.display_name(name).about(bio);
if let Ok(url) = Url::from_str(&avatar) {
new_metadata = new_metadata.picture(url);
};
if let Ok(url) = Url::from_str(&website) {
new_metadata = new_metadata.website(url);
}
cx.background_spawn(async move {
let client = nostr_client();
let output = client.set_metadata(&new_metadata).await?;
let event = client.database().event_by_id(&output.val).await?;
Ok(event)
})
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
}
impl Render for EditProfile {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.child(
div()
.w_full()
.h_32()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.map(|this| {
let picture = self.avatar_input.read(cx).value();
if picture.is_empty() {
this.child(
img("brand/avatar.png")
.rounded_full()
.size_10()
.flex_shrink_0(),
)
} else {
this.child(
img(picture.clone())
.rounded_full()
.size_10()
.flex_shrink_0(),
)
}
})
.child(
Button::new("upload")
.icon(IconName::Upload)
.label(t!("common.change"))
.ghost()
.small()
.disabled(self.is_loading || self.is_submitting)
.loading(self.is_loading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_sm()
.child(SharedString::new(t!("profile.label_name")))
.child(TextInput::new(&self.name_input).small()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_sm()
.child(SharedString::new(t!("profile.label_website")))
.child(TextInput::new(&self.website_input).small()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_sm()
.child(SharedString::new(t!("profile.label_bio")))
.child(TextInput::new(&self.bio_input).small()),
)
}
}

View File

@@ -1,694 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use client_keys::ClientKeys;
use common::display::TextUtils;
use common::handle_auth::CoopAuthUrlHandler;
use global::constants::{APP_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, red, relative, AnyElement, App, AppContext, ClipboardItem, Context, Entity,
EventEmitter, FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
};
use i18n::{shared_t, t};
use identity::Identity;
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::popup_menu::PopupMenu;
use ui::{ContextModal, Disableable, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
Login::new(window, cx)
}
pub struct Login {
key_input: Entity<InputState>,
relay_input: Entity<InputState>,
connection_string: Entity<NostrConnectURI>,
qr_image: Entity<Option<Arc<Image>>>,
// Error for the key input
error: Entity<Option<SharedString>>,
countdown: Entity<Option<u64>>,
logging_in: bool,
// Panel
name: SharedString,
focus_handle: FocusHandle,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 3]>,
}
impl Login {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
// nsec or bunker_uri (NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md)
let key_input =
cx.new(|cx| InputState::new(window, cx).placeholder("nsec... or bunker://..."));
let relay_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(NOSTR_CONNECT_RELAY)
.placeholder(NOSTR_CONNECT_RELAY)
});
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
//
// Direct connection initiated by the client
let connection_string = cx.new(|cx| {
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
let client_keys = ClientKeys::get_global(cx).keys();
NostrConnectURI::client(client_keys.public_key(), vec![relay], APP_NAME)
});
let qr_image = cx.new(|_| None);
let error = cx.new(|_| None);
let countdown = cx.new(|_| None);
let mut subscriptions = smallvec![];
// Subscribe to key input events and process login when the user presses enter
subscriptions.push(
cx.subscribe_in(&key_input, window, |this, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.login(window, cx);
}
}),
);
// Subscribe to relay input events and change relay when the user presses enter
subscriptions.push(
cx.subscribe_in(&relay_input, window, |this, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.change_relay(window, cx);
}
}),
);
// Observe changes to the Nostr Connect URI and wait for a connection
subscriptions.push(cx.observe_in(
&connection_string,
window,
|this, entity, window, cx| {
let connection_string = entity.read(cx).clone();
let client_keys = ClientKeys::get_global(cx).keys();
// Update the QR Image with the new connection string
this.qr_image.update(cx, |this, cx| {
*this = connection_string.to_string().to_qr();
cx.notify();
});
match NostrConnect::new(
connection_string,
client_keys,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
) {
Ok(mut signer) => {
// Automatically open auth url
signer.auth_url_handler(CoopAuthUrlHandler);
// Wait for connection in the background
this.wait_for_connection(signer, window, cx);
}
Err(e) => {
window.push_notification(
Notification::error(e.to_string()).title("Nostr Connect"),
cx,
);
}
}
},
));
// Create a Nostr Connect URI and QR Code 800ms after opening the login screen
cx.spawn_in(window, async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(800))
.await;
this.update(cx, |this, cx| {
this.connection_string.update(cx, |_, cx| {
cx.notify();
})
})
.ok();
})
.detach();
Self {
name: "Login".into(),
focus_handle: cx.focus_handle(),
logging_in: false,
countdown,
key_input,
relay_input,
connection_string,
qr_image,
error,
subscriptions,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.logging_in {
return;
};
// Prevent duplicate login requests
self.set_logging_in(true, cx);
// Disable the input
self.key_input.update(cx, |this, cx| {
this.set_loading(true, cx);
this.set_disabled(true, cx);
});
// Content can be secret key or bunker://
match self.key_input.read(cx).value().to_string() {
s if s.starts_with("nsec1") => self.ask_for_password(s, window, cx),
s if s.starts_with("ncryptsec1") => self.ask_for_password(s, window, cx),
s if s.starts_with("bunker://") => self.login_with_bunker(s, window, cx),
_ => self.set_error(t!("login.invalid_key"), window, cx),
};
}
fn ask_for_password(&mut self, content: String, window: &mut Window, cx: &mut Context<Self>) {
let current_view = cx.entity().downgrade();
let is_ncryptsec = content.starts_with("ncryptsec1");
let pwd_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let weak_pwd_input = pwd_input.downgrade();
let confirm_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let weak_confirm_input = confirm_input.downgrade();
window.open_modal(cx, move |this, _window, cx| {
let weak_pwd_input = weak_pwd_input.clone();
let weak_confirm_input = weak_confirm_input.clone();
let view_cancel = current_view.clone();
let view_ok = current_view.clone();
let label: SharedString = if !is_ncryptsec {
t!("login.set_password").into()
} else {
t!("login.password_to_decrypt").into()
};
let description: SharedString = if is_ncryptsec {
t!("login.password_description").into()
} else {
t!("login.password_description_full").into()
};
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.on_cancel(move |_, window, cx| {
view_cancel
.update(cx, |this, cx| {
this.set_error(t!("login.password_is_required"), window, cx);
})
.ok();
true
})
.on_ok(move |_, window, cx| {
let value = weak_pwd_input
.read_with(cx, |state, _cx| state.value().to_owned())
.ok();
let confirm = weak_confirm_input
.read_with(cx, |state, _cx| state.value().to_owned())
.ok();
view_ok
.update(cx, |this, cx| {
this.verify_password(value, confirm, is_ncryptsec, window, cx);
})
.ok();
true
})
.child(
div()
.w_full()
.flex()
.flex_col()
.gap_2()
.text_sm()
.child(
div()
.flex()
.flex_col()
.gap_1()
.child(label)
.child(TextInput::new(&pwd_input).small()),
)
.when(content.starts_with("nsec1"), |this| {
this.child(
div()
.flex()
.flex_col()
.gap_1()
.child(SharedString::new(t!("login.confirm_password")))
.child(TextInput::new(&confirm_input).small()),
)
})
.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(description),
),
)
});
}
fn verify_password(
&mut self,
password: Option<SharedString>,
confirm: Option<SharedString>,
is_ncryptsec: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(password) = password else {
self.set_error(t!("login.password_is_required"), window, cx);
return;
};
if password.is_empty() {
self.set_error(t!("login.password_is_required"), window, cx);
return;
}
// Skip verification if key is ncryptsec
if is_ncryptsec {
self.login_with_keys(password.to_string(), window, cx);
return;
}
let Some(confirm) = confirm else {
self.set_error(t!("login.must_confirm_password"), window, cx);
return;
};
if confirm.is_empty() {
self.set_error(t!("login.must_confirm_password"), window, cx);
return;
}
if password != confirm {
self.set_error(t!("login.password_not_match"), window, cx);
return;
}
self.login_with_keys(password.to_string(), window, cx);
}
fn login_with_keys(&mut self, password: String, window: &mut Window, cx: &mut Context<Self>) {
let value = self.key_input.read(cx).value().to_string();
let secret_key = if value.starts_with("nsec1") {
SecretKey::parse(&value).ok()
} else if value.starts_with("ncryptsec1") {
EncryptedSecretKey::from_bech32(&value)
.map(|enc| enc.decrypt(&password).ok())
.unwrap_or_default()
} else {
None
};
if let Some(secret_key) = secret_key {
let keys = Keys::new(secret_key);
Identity::global(cx).update(cx, |this, cx| {
this.write_keys(&keys, password, cx);
this.set_signer(keys, window, cx);
});
} else {
self.set_error(t!("login.key_invalid"), window, cx);
}
}
fn login_with_bunker(&mut self, content: String, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectURI::parse(content) else {
self.set_error(t!("login.bunker_invalid"), window, cx);
return;
};
let client_keys = ClientKeys::get_global(cx).keys();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT / 8);
// .unwrap() is fine here because there's no error handling for bunker uri
let mut signer = NostrConnect::new(uri, client_keys, timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Start countdown
cx.spawn_in(window, async move |this, cx| {
for i in (0..=NOSTR_CONNECT_TIMEOUT / 8).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})
.ok();
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
})
.detach();
// Handle connection
cx.spawn_in(window, async move |this, cx| {
match signer.bunker_uri().await {
Ok(bunker_uri) => {
cx.update(|window, cx| {
window.push_notification(t!("login.logging_in"), cx);
Identity::global(cx).update(cx, |this, cx| {
this.write_bunker(&bunker_uri, cx);
this.set_signer(signer, window, cx);
});
})
.ok();
}
Err(error) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
// Force reset the client keys without notify UI
ClientKeys::global(cx).update(cx, |this, cx| {
log::info!("Timeout occurred. Reset client keys");
this.force_new_keys(cx);
});
this.set_error(error.to_string(), window, cx);
})
.ok();
})
.ok();
}
}
})
.detach();
}
fn wait_for_connection(
&mut self,
signer: NostrConnect,
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.spawn_in(window, async move |this, cx| {
match signer.bunker_uri().await {
Ok(uri) => {
cx.update(|window, cx| {
Identity::global(cx).update(cx, |this, cx| {
this.write_bunker(&uri, cx);
this.set_signer(signer, window, cx);
});
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
// Only send notifications on the login screen
this.update(cx, |_, cx| {
window.push_notification(
Notification::error(e.to_string()).title("Nostr Connect"),
cx,
);
})
.ok();
})
.ok();
}
}
})
.detach();
}
fn change_relay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(relay_url) = RelayUrl::parse(self.relay_input.read(cx).value().to_string().as_str())
else {
window.push_notification(Notification::error(t!("relays.invalid")), cx);
return;
};
let client_keys = ClientKeys::get_global(cx).keys();
let uri = NostrConnectURI::client(client_keys.public_key(), vec![relay_url], "Coop");
self.connection_string.update(cx, |this, cx| {
*this = uri;
cx.notify();
});
}
fn set_error(
&mut self,
message: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Reset the log in state
self.set_logging_in(false, cx);
// Reset the countdown
self.set_countdown(None, cx);
// Update error message
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
// Re enable the input
self.key_input.update(cx, |this, cx| {
this.set_value("", window, cx);
this.set_loading(false, cx);
this.set_disabled(false, cx);
});
// Clear the error message after 3 secs
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(3)).await;
this.update(cx, |this, cx| {
this.error.update(cx, |this, cx| {
*this = None;
cx.notify();
});
})
.ok();
})
.detach();
}
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.logging_in = status;
cx.notify();
}
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
self.countdown.update(cx, |this, cx| {
*this = i;
cx.notify();
});
}
}
impl Panel for Login {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Login {}
impl Focusable for Login {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Login {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.relative()
.flex()
.child(
div()
.h_full()
.flex_1()
.flex()
.items_center()
.justify_center()
.child(
div()
.w_80()
.flex()
.flex_col()
.gap_8()
.child(
div()
.text_center()
.child(
div()
.text_center()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(shared_t!("login.title")),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("login.key_description")),
),
)
.child(
div()
.flex()
.flex_col()
.gap_3()
.child(TextInput::new(&self.key_input))
.child(
Button::new("login")
.label(t!("common.continue"))
.primary()
.loading(self.logging_in)
.disabled(self.logging_in)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(shared_t!("login.approve_message", i = i)),
)
})
.when_some(self.error.read(cx).clone(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(red())
.child(error),
)
}),
),
),
)
.child(
div().flex_1().p_1().child(
div()
.size_full()
.flex()
.items_center()
.justify_center()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_3()
.text_center()
.child(
div()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.2))
.text_color(cx.theme().text)
.child(shared_t!("login.nostr_connect")),
)
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(shared_t!("login.scan_qr")),
),
)
.when_some(self.qr_image.read(cx).clone(), |this, qr| {
this.child(
div()
.id("")
.mb_2()
.p_2()
.size_72()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.rounded_2xl()
.shadow_md()
.when(cx.theme().mode.is_dark(), |this| {
this.shadow_none()
.border_1()
.border_color(cx.theme().border)
})
.bg(cx.theme().background)
.child(img(qr).h_64())
.on_click(cx.listener(move |this, _, window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
this.connection_string.read(cx).to_string(),
));
window.push_notification(t!("common.copied"), cx);
})),
)
})
.child(
div()
.w_full()
.flex()
.items_center()
.justify_center()
.gap_1()
.child(TextInput::new(&self.relay_input).xsmall())
.child(
Button::new("change")
.label(t!("common.change"))
.ghost()
.xsmall()
.on_click(cx.listener(
move |this, _, window, cx| {
this.change_relay(window, cx);
},
)),
),
),
),
),
)
}
}

View File

@@ -1,369 +0,0 @@
use std::time::Duration;
use anyhow::{anyhow, Error};
use global::constants::NIP17_RELAYS;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
TextAlign, UniformList, Window,
};
use i18n::{shared_t, t};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<MessagingRelays> {
cx.new(|cx| MessagingRelays::new(window, cx))
}
pub fn relay_button() -> impl IntoElement {
div().child(
Button::new("dm-relays")
.icon(IconName::Info)
.label(t!("relays.button_label"))
.warning()
.xsmall()
.rounded(ButtonRounded::Full)
.on_click(move |_, window, cx| {
let title = SharedString::new(t!("relays.modal_title"));
let view = cx.new(|cx| MessagingRelays::new(window, cx));
let weak_view = view.downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
modal
.confirm()
.title(title.clone())
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
this.set_relays(window, cx);
})
.ok();
// true to close the modal
false
})
})
}),
)
}
pub struct MessagingRelays {
input: Entity<InputState>,
relays: Vec<RelayUrl>,
error: Option<SharedString>,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
}
impl MessagingRelays {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_new::<Self>(move |this, window, cx| {
if let Some(window) = window {
this.load(window, cx);
}
}));
subscriptions.push(cx.subscribe_in(
&input,
window,
move |this: &mut Self, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add(window, cx);
}
},
));
Self {
input,
subscriptions,
relays: vec![],
error: None,
}
}
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first() {
let relays = event
.tags
.filter(TagKind::Relay)
.filter_map(|tag| RelayUrl::parse(tag.content()?).ok())
.collect::<Vec<_>>();
Ok(relays)
} else {
Err(anyhow!("Not found."))
}
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(relays) = task.await {
this.update(cx, |this, cx| {
this.relays = relays;
cx.notify();
})
.ok();
}
})
.detach();
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.input.read(cx).value().to_string();
if !value.starts_with("ws") {
return;
}
if let Ok(url) = RelayUrl::parse(&value) {
if !self.relays.contains(&url) {
self.relays.push(url);
}
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
cx.notify();
}
}
fn remove(&mut self, ix: usize, _window: &mut Window, cx: &mut Context<Self>) {
self.relays.remove(ix);
cx.notify();
}
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
where
E: Into<SharedString>,
{
self.error = Some(error.into());
cx.notify();
// Clear the error message after a delay
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})
.ok();
})
.ok();
})
.detach();
}
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.relays.is_empty() {
self.set_error(t!("relays.empty"), window, cx);
return;
};
let relays = self.relays.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let client = nostr_client();
let tags: Vec<Tag> = relays
.iter()
.map(|relay| Tag::relay(relay.clone()))
.collect();
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
// Set messaging relays
client.send_event_builder(builder).await?;
// Connect to messaging relays
for relay in relays.into_iter() {
_ = client.add_relay(&relay).await;
_ = client.connect_relay(&relay).await;
}
Ok(())
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
window.close_modal(cx);
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), window, cx);
})
.ok();
})
.ok();
}
};
})
.detach();
}
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
let relays = self.relays.clone();
let total = relays.len();
uniform_list(
"relays",
total,
cx.processor(move |_, range, _window, cx| {
let mut items = Vec::new();
for ix in range {
let item = relays.get(ix).map(|i: &RelayUrl| i.to_string()).unwrap();
items.push(
div().group("").w_full().h_9().py_0p5().child(
div()
.px_2()
.h_full()
.w_full()
.flex()
.items_center()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.text_xs()
.child(item)
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click(cx.listener(move |this, _, window, cx| {
this.remove(ix, window, cx)
})),
),
),
)
}
items
}),
)
.w_full()
.min_h(px(200.))
}
fn render_empty(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.h_20()
.mb_2()
.justify_center()
.text_sm()
.text_align(TextAlign::Center)
.child(SharedString::new(t!("relays.add_some_relays")))
}
}
impl Render for MessagingRelays {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.text_sm()
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("relays.description")),
)
.child(
v_flex()
.gap_2()
.child(
h_flex()
.gap_1()
.w_full()
.child(TextInput::new(&self.input).small())
.child(
Button::new("add")
.icon(IconName::PlusFill)
.label(t!("common.add"))
.ghost()
.on_click(cx.listener(move |this, _, window, cx| {
this.add(window, cx);
})),
),
)
.child(
h_flex()
.gap_2()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(shared_t!("relays.recommended")),
)
.child(h_flex().gap_1().children({
NIP17_RELAYS.iter().map(|&relay| {
div()
.id(relay)
.group("")
.py_0p5()
.px_1p5()
.text_xs()
.text_center()
.bg(cx.theme().secondary_background)
.hover(|this| this.bg(cx.theme().secondary_hover))
.active(|this| this.bg(cx.theme().secondary_active))
.rounded_full()
.child(relay)
.on_click(cx.listener(move |this, _, window, cx| {
this.input.update(cx, |this, cx| {
this.set_value(relay, window, cx);
});
this.add(window, cx);
}))
})
})),
)
.when_some(self.error.as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
)
.map(|this| {
if !self.relays.is_empty() {
this.child(self.render_list(window, cx))
} else {
this.child(self.render_empty(window, cx))
}
})
}
}

View File

@@ -1,14 +1,7 @@
pub mod backup_keys;
pub mod chat;
pub mod compose;
pub mod edit_profile;
pub mod login;
pub mod messaging_relays;
pub mod new_account;
pub mod onboarding;
pub mod preferences;
pub mod screening;
pub mod sidebar;
pub mod setup_relay;
pub mod startup;
pub mod user_profile;
pub mod welcome;

View File

@@ -1,275 +0,0 @@
use anyhow::anyhow;
use common::nip96::nip96_upload;
use global::nostr_client;
use gpui::{
div, relative, rems, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity,
EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions,
Render, SharedString, Styled, WeakEntity, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputState, TextInput};
use ui::popup_menu::PopupMenu;
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
NewAccount::new(window, cx)
}
pub struct NewAccount {
name_input: Entity<InputState>,
avatar_input: Entity<InputState>,
is_uploading: bool,
is_submitting: bool,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl NewAccount {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let name_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(SharedString::new(t!("profile.placeholder_name")))
});
let avatar_input =
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.png"));
Self {
name_input,
avatar_input,
is_uploading: false,
is_submitting: false,
name: "New Account".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.submitting(true, cx);
let identity = Identity::global(cx);
let avatar = self.avatar_input.read(cx).value().to_string();
let name = self.name_input.read(cx).value().to_string();
// Build metadata
let mut metadata = Metadata::new().display_name(name.clone()).name(name);
if let Ok(url) = Url::parse(&avatar) {
metadata = metadata.picture(url);
};
identity.update(cx, |this, cx| {
this.new_identity(metadata, window, cx);
});
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.uploading(true, cx);
// Get the user's configured NIP96 server
let nip96_server = AppSettings::get_media_server(cx);
// Open native file dialog
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
let task = Tokio::spawn(cx, async move {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
if let Some(path) = paths.pop() {
let file = fs::read(path).await?;
let url = nip96_upload(nostr_client(), &nip96_server, file).await?;
Ok(url)
} else {
Err(anyhow!("Path not found"))
}
}
Ok(None) => Err(anyhow!("User cancelled")),
Err(e) => Err(anyhow!("File dialog error: {e}")),
}
});
cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(task.await.map_err(|e| e.into())) {
Ok(Ok(url)) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.uploading(false, cx);
this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
});
})
.ok();
})
.ok();
}
Ok(Err(e)) => {
Self::notify_error(cx, this, e.to_string());
}
Err(e) => {
Self::notify_error(cx, this, e.to_string());
}
}
})
.detach();
}
fn notify_error(cx: &mut AsyncWindowContext, entity: WeakEntity<NewAccount>, e: String) {
cx.update(|window, cx| {
entity
.update(cx, |this, cx| {
window.push_notification(e, cx);
this.uploading(false, cx);
})
.ok();
})
.ok();
}
fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
cx.notify();
}
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_uploading = status;
cx.notify();
}
}
impl Panel for NewAccount {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for NewAccount {}
impl Focusable for NewAccount {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for NewAccount {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.relative()
.items_center()
.justify_center()
.gap_10()
.child(
div()
.text_lg()
.text_center()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::new(t!("new_account.title"))),
)
.child(
v_flex()
.w_96()
.gap_4()
.child(
v_flex()
.gap_1()
.text_sm()
.child(SharedString::new(t!("new_account.name")))
.child(TextInput::new(&self.name_input).small()),
)
.child(
v_flex()
.gap_1()
.child(
div()
.text_sm()
.child(SharedString::new(t!("new_account.avatar"))),
)
.child(
v_flex()
.p_1()
.h_32()
.w_full()
.items_center()
.justify_center()
.gap_2()
.rounded(cx.theme().radius)
.border_1()
.border_dashed()
.border_color(cx.theme().border)
.child(
Avatar::new(self.avatar_input.read(cx).value().to_string())
.size(rems(2.25)),
)
.child(
Button::new("upload")
.icon(IconName::Plus)
.label(t!("common.upload"))
.ghost()
.small()
.rounded(ButtonRounded::Full)
.disabled(self.is_submitting)
.loading(self.is_uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
),
)
.child(divider(cx))
.child(
Button::new("submit")
.label(SharedString::new(t!("common.continue")))
.primary()
.loading(self.is_submitting)
.disabled(self.is_submitting || self.is_uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.submit(window, cx);
})),
),
)
}
}

View File

@@ -1,40 +1,70 @@
use anyhow::anyhow;
use common::display::DisplayProfile;
use global::constants::ACCOUNT_D;
use global::nostr_client;
use std::sync::Arc;
use std::time::Duration;
use common::{TextUtils, CLIENT_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Window,
div, img, px, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement, Render,
SharedString, StatefulInteractiveElement, Styled, Task, Window,
};
use i18n::t;
use identity::Identity;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use key_store::{KeyItem, KeyStore};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::checkbox::Checkbox;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::popup_menu::PopupMenu;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt};
use ui::notification::Notification;
use ui::{divider, h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
use crate::chatspace;
use crate::chatspace::{self};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
Onboarding::new(window, cx)
}
#[derive(Debug, Clone)]
pub enum NostrConnectApp {
Nsec(String),
Amber(String),
Aegis(String),
}
impl NostrConnectApp {
pub fn all() -> Vec<Self> {
vec![
NostrConnectApp::Nsec("https://nsec.app".to_string()),
NostrConnectApp::Amber("https://github.com/greenart7c3/Amber".to_string()),
NostrConnectApp::Aegis("https://github.com/ZharlieW/Aegis".to_string()),
]
}
pub fn url(&self) -> &str {
match self {
Self::Nsec(url) | Self::Amber(url) | Self::Aegis(url) => url,
}
}
pub fn as_str(&self) -> String {
match self {
NostrConnectApp::Nsec(_) => "nsec.app (Desktop)".into(),
NostrConnectApp::Amber(_) => "Amber (Android)".into(),
NostrConnectApp::Aegis(_) => "Aegis (iOS)".into(),
}
}
}
pub struct Onboarding {
app_keys: Keys,
qr_code: Option<Arc<Image>>,
/// Panel
name: SharedString,
local_account: Entity<Option<Profile>>,
loading: bool,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
/// Background tasks
_tasks: SmallVec<[Task<()>; 1]>,
}
impl Onboarding {
@@ -43,62 +73,134 @@ impl Onboarding {
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let local_account = cx.new(|_| None);
let app_keys = Keys::generate();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let task = cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ACCOUNT_D)
.limit(1);
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
let qr_code = uri.to_string().to_qr();
if let Some(event) = nostr_client().database().query(filter).await?.first_owned() {
let public_key = event
.tags
.public_keys()
.copied()
.collect_vec()
.first()
.cloned()
.unwrap();
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
//
// Direct connection initiated by the client
let signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
let metadata = nostr_client()
.database()
.metadata(public_key)
.await?
.unwrap_or_default();
let mut tasks = smallvec![];
Ok(Profile::new(public_key, metadata))
} else {
Err(anyhow!("Not found"))
}
});
tasks.push(
// Wait for nostr connect
cx.spawn_in(window, async move |this, cx| {
let result = signer.bunker_uri().await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(uri) => {
this.save_connection(&uri, window, cx);
this.connect(signer, cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
}),
);
Self {
qr_code,
app_keys,
name: "Onboarding".into(),
focus_handle: cx.focus_handle(),
_tasks: tasks,
}
}
fn save_connection(
&mut self,
uri: &NostrConnectUri,
window: &mut Window,
cx: &mut Context<Self>,
) {
let keystore = KeyStore::global(cx).read(cx).backend();
let username = self.app_keys.public_key().to_hex();
let secret = self.app_keys.secret_key().to_secret_bytes();
let mut clean_uri = uri.to_string();
// Clear the secret parameter in the URI if it exists
if let Some(s) = uri.secret() {
clean_uri = clean_uri.replace(s, "");
}
cx.spawn_in(window, async move |this, cx| {
if let Ok(profile) = task.await {
this.update(cx, |this, cx| {
this.local_account.update(cx, |this, cx| {
*this = Some(profile);
cx.notify();
});
let user_url = KeyItem::User.to_string();
let bunker_url = KeyItem::Bunker.to_string();
let user_password = clean_uri.into_bytes();
// Write bunker uri to keyring for further connection
if let Err(e) = keystore
.write_credentials(&user_url, "bunker", &user_password, cx)
.await
{
this.update_in(cx, |_, window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
// Write the app keys for further connection
if let Err(e) = keystore
.write_credentials(&bunker_url, &username, &secret, cx)
.await
{
this.update_in(cx, |_, window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
})
.detach();
Self {
local_account,
name: "Onboarding".into(),
loading: false,
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
fn connect(&mut self, signer: NostrConnect, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
client.set_signer(signer).await;
})
.detach();
}
fn render_apps(&self, cx: &Context<Self>) -> impl IntoIterator<Item = impl IntoElement> {
let all_apps = NostrConnectApp::all();
let mut items = Vec::with_capacity(all_apps.len());
for (ix, item) in all_apps.into_iter().enumerate() {
items.push(self.render_app(ix, item.as_str(), item.url(), cx));
}
items
}
fn render_app<T>(&self, ix: usize, label: T, url: &str, cx: &Context<Self>) -> impl IntoElement
where
T: Into<SharedString>,
{
div()
.id(ix)
.flex_1()
.rounded(cx.theme().radius)
.py_0p5()
.px_2()
.bg(cx.theme().ghost_element_background_alt)
.child(label.into())
.on_click({
let url = url.to_owned();
move |_e, _window, cx| {
cx.open_url(&url);
}
})
}
}
@@ -110,18 +212,6 @@ impl Panel for Onboarding {
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
}
impl EventEmitter<PanelEvent> for Onboarding {}
@@ -133,160 +223,141 @@ impl Focusable for Onboarding {
}
impl Render for Onboarding {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
let auto_login = AppSettings::get_auto_login(cx);
let proxy = AppSettings::get_proxy_user_avatars(cx);
div()
.py_4()
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.child(
div()
.mb_10()
.flex()
.flex_col()
v_flex()
.flex_1()
.h_full()
.gap_10()
.items_center()
.gap_4()
.justify_center()
.child(
svg()
.path("brand/coop.svg")
.size_16()
.text_color(cx.theme().elevated_surface_background),
)
.child(
div()
.text_center()
v_flex()
.items_center()
.justify_center()
.gap_4()
.child(
div()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::new(t!("welcome.title"))),
svg()
.path("brand/coop.svg")
.size_16()
.text_color(cx.theme().elevated_surface_background),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("welcome.subtitle"))),
.text_center()
.child(
div()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::from("Welcome to Coop")),
)
.child(div().text_color(cx.theme().text_muted).child(
SharedString::from("Chat Freely, Stay Private on Nostr."),
)),
),
),
)
.map(|this| {
if let Some(profile) = self.local_account.read(cx).as_ref() {
this.relative()
.child(
div()
.id("account")
.mb_3()
.h_10()
.w_72()
.bg(cx.theme().element_background)
.text_color(cx.theme().element_foreground)
.rounded_lg()
.text_sm()
.map(|this| {
if self.loading {
this.child(
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(Indicator::new().small()),
)
} else {
this.child(
div()
.h_full()
.flex()
.items_center()
.justify_center()
.gap_2()
.child(SharedString::new(t!(
"onboarding.choose_account"
)))
.child(
div()
.flex()
.items_center()
.gap_1()
.font_semibold()
.child(
Avatar::new(profile.avatar_url(proxy))
.size(rems(1.5)),
)
.child(
div()
.pb_px()
.child(profile.display_name()),
),
),
)
}
})
.hover(|this| this.bg(cx.theme().element_hover))
.on_click(cx.listener(|this, _, window, cx| {
this.set_loading(true, cx);
Identity::global(cx).update(cx, |this, cx| {
this.load(window, cx);
});
})),
)
.child(
Checkbox::new("auto_login")
.label(SharedString::new(t!("onboarding.auto_login")))
.checked(auto_login)
.on_click(move |_, _window, cx| {
AppSettings::update_auto_login(!auto_login, cx);
}),
)
.child(
div().w_24().absolute().bottom_2().right_2().child(
Button::new("logout")
.icon(IconName::Logout)
.label(SharedString::new(t!("user.sign_out")))
.danger()
.xsmall()
.rounded(ButtonRounded::Full)
.disabled(self.loading)
.on_click(|_, window, cx| {
Identity::global(cx).update(cx, |this, cx| {
this.unload(window, cx);
});
}),
),
)
} else {
this.child(
div()
.w_72()
.flex()
.flex_col()
.gap_2()
)
.child(
v_flex()
.w_80()
.gap_3()
.child(
Button::new("continue_btn")
.icon(Icon::new(IconName::ArrowRight))
.label(SharedString::new(t!("onboarding.start_messaging")))
.label(SharedString::from("Start Messaging on Nostr"))
.primary()
.large()
.bold()
.reverse()
.on_click(cx.listener(move |_, _, window, cx| {
chatspace::new_account(window, cx);
})),
)
.child(
Button::new("login_btn")
.label(SharedString::new(t!("onboarding.already_have_account")))
.ghost()
.underline()
h_flex()
.my_1()
.gap_1()
.child(divider(cx))
.child(div().text_sm().text_color(cx.theme().text_muted).child(
SharedString::from(
"Already have an account? Continue with",
),
))
.child(divider(cx)),
)
.child(
Button::new("key")
.label("Secret Key or Bunker")
.large()
.ghost_alt()
.on_click(cx.listener(move |_, _, window, cx| {
chatspace::login(window, cx);
})),
),
)
}
})
),
)
.child(
div()
.relative()
.p_2()
.flex_1()
.h_full()
.rounded(cx.theme().radius_lg)
.child(
v_flex()
.size_full()
.justify_center()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius_lg)
.child(
v_flex()
.gap_5()
.items_center()
.justify_center()
.when_some(self.qr_code.as_ref(), |this, qr| {
this.child(
img(qr.clone())
.size(px(256.))
.rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_lg())
.border_1()
.border_color(cx.theme().element_active),
)
})
.child(
v_flex()
.justify_center()
.items_center()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::from(
"Continue with Nostr Connect",
)),
)
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from(
"Use Nostr Connect apps to scan the code",
)),
)
.child(
h_flex()
.mt_2()
.gap_1()
.text_xs()
.justify_center()
.children(self.render_apps(cx)),
),
),
),
),
)
}
}

View File

@@ -1,26 +1,17 @@
use common::display::DisplayProfile;
use gpui::http_client::Url;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, relative, rems, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window,
div, px, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Window,
};
use i18n::t;
use identity::Identity;
use registry::Registry;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::switch::Switch;
use ui::{v_flex, ContextModal, IconName, Sizable, Size, StyledExt};
use crate::views::{edit_profile, messaging_relays};
use ui::{h_flex, v_flex, IconName, Sizable, Size, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
Preferences::new(window, cx)
cx.new(|cx| Preferences::new(window, cx))
}
pub struct Preferences {
@@ -28,169 +19,30 @@ pub struct Preferences {
}
impl Preferences {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| {
let media_server = AppSettings::get_media_server(cx).to_string();
let media_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(media_server.clone())
.placeholder(media_server)
});
Self { media_input }
})
}
fn open_edit_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
let view = edit_profile::init(window, cx);
let weak_view = view.downgrade();
let title = SharedString::new(t!("profile.title"));
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
modal
.confirm()
.title(title.clone())
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
let set_metadata = this.set_metadata(cx);
cx.spawn_in(window, async move |_, cx| {
match set_metadata.await {
Ok(event) => {
if let Some(event) = event {
cx.update(|_, cx| {
Registry::global(cx).update(cx, |this, cx| {
this.insert_or_update_person(event, cx);
});
})
.ok();
}
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
};
})
.detach();
})
.ok();
// true to close the modal
true
})
pub fn new(window: &mut Window, cx: &mut App) -> Self {
let media_server = AppSettings::get_media_server(cx).to_string();
let media_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(media_server.clone())
.placeholder(media_server)
});
}
fn open_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let title = SharedString::new(t!("relays.modal_title"));
let view = messaging_relays::init(window, cx);
let weak_view = view.downgrade();
window.open_modal(cx, move |this, _window, _cx| {
let weak_view = weak_view.clone();
this.confirm()
.title(title.clone())
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
this.set_relays(window, cx);
})
.ok();
// true to close the modal
false
})
});
Self { media_input }
}
}
impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let input_state = self.media_input.downgrade();
let profile = Identity::read_global(cx)
.public_key()
.map(|pk| Registry::read_global(cx).get_person(&pk, cx));
let backup_messages = AppSettings::get_backup_messages(cx);
let auto_auth = AppSettings::get_auto_auth(cx);
let backup = AppSettings::get_backup_messages(cx);
let screening = AppSettings::get_screening(cx);
let contact_bypass = AppSettings::get_contact_bypass(cx);
let proxy_avatar = AppSettings::get_proxy_user_avatars(cx);
let hide_avatar = AppSettings::get_hide_user_avatars(cx);
let bypass = AppSettings::get_contact_bypass(cx);
let proxy = AppSettings::get_proxy_user_avatars(cx);
let hide = AppSettings::get_hide_user_avatars(cx);
let input_state = self.media_input.downgrade();
v_flex()
.child(
v_flex()
.py_2()
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(SharedString::new(t!("preferences.account_header"))),
)
.when_some(profile, |this, profile| {
this.child(
div()
.w_full()
.flex()
.justify_between()
.items_center()
.child(
div()
.id("current-user")
.flex()
.items_center()
.gap_2()
.child(
Avatar::new(profile.avatar_url(proxy_avatar))
.size(rems(2.4)),
)
.child(
div()
.flex_1()
.text_sm()
.child(
div()
.line_height(relative(1.3))
.font_semibold()
.child(profile.display_name()),
)
.child(
div()
.line_height(relative(1.3))
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!(
"preferences.see_your_profile"
))),
),
)
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_edit_profile(window, cx);
})),
)
.child(
Button::new("relays")
.label("Messaging Relays")
.ghost()
.small()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_relays(window, cx);
})),
),
)
}),
)
.child(
v_flex()
.py_2()
@@ -201,39 +53,48 @@ impl Render for Preferences {
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(SharedString::new(t!("preferences.media_server_header"))),
.child(SharedString::from("Relay and Media")),
)
.child(
div()
v_flex()
.my_1()
.flex()
.items_start()
.gap_1()
.child(TextInput::new(&self.media_input).xsmall())
.child(
Button::new("update")
.icon(IconName::CheckCircleFill)
.ghost()
.with_size(Size::Size(px(26.)))
.on_click(move |_, window, cx| {
if let Some(input) = input_state.upgrade() {
let Ok(url) = Url::parse(input.read(cx).value()) else {
window.push_notification(
t!("preferences.url_not_valid"),
cx,
);
return;
};
AppSettings::update_media_server(url, cx);
}
}),
h_flex()
.gap_1()
.child(TextInput::new(&self.media_input).xsmall())
.child(
Button::new("update")
.icon(IconName::Check)
.ghost()
.with_size(Size::Size(px(26.)))
.on_click(move |_, _window, cx| {
if let Some(input) = input_state.upgrade() {
let Ok(url) =
Url::parse(&input.read(cx).value())
else {
return;
};
AppSettings::update_media_server(url, cx);
}
}),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Coop currently only supports NIP-96 media servers.")),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("preferences.media_description"))),
Switch::new("auth")
.label("Automatically authenticate for known relays")
.description("After you approve the authentication request, Coop will automatically complete this step next time.")
.checked(auto_auth)
.on_click(move |_, _window, cx| {
AppSettings::update_auto_auth(!auto_auth, cx);
}),
),
)
.child(
@@ -247,15 +108,15 @@ impl Render for Preferences {
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(SharedString::new(t!("preferences.messages_header"))),
.child(SharedString::from("Messages")),
)
.child(
v_flex()
.gap_2()
.child(
Switch::new("screening")
.label(t!("preferences.screening_label"))
.description(t!("preferences.screening_description"))
.label("Screening")
.description("When opening a chat request, Coop will show a popup to help you verify the sender.")
.checked(screening)
.on_click(move |_, _window, cx| {
AppSettings::update_screening(!screening, cx);
@@ -263,20 +124,20 @@ impl Render for Preferences {
)
.child(
Switch::new("bypass")
.label(t!("preferences.bypass_label"))
.description(t!("preferences.bypass_description"))
.checked(contact_bypass)
.label("Skip screening for contacts")
.description("Requests from your contacts will automatically go to inbox.")
.checked(bypass)
.on_click(move |_, _window, cx| {
AppSettings::update_contact_bypass(!contact_bypass, cx);
AppSettings::update_contact_bypass(!bypass, cx);
}),
)
.child(
Switch::new("backup_messages")
.label(t!("preferences.backup_messages_label"))
.description(t!("preferences.backup_description"))
.checked(backup_messages)
Switch::new("backup")
.label("Backup messages")
.description("When you send a message, Coop will also forward it to your configured Messaging Relays. Disabling this will cause all messages sent during the current session to disappear when the app is closed.")
.checked(backup)
.on_click(move |_, _window, cx| {
AppSettings::update_backup_messages(!backup_messages, cx);
AppSettings::update_backup_messages(!backup, cx);
}),
),
),
@@ -292,27 +153,27 @@ impl Render for Preferences {
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(SharedString::new(t!("preferences.display_header"))),
.child(SharedString::from("Display")),
)
.child(
v_flex()
.gap_2()
.child(
Switch::new("hide_user_avatars")
.label(t!("preferences.hide_avatars_label"))
.description(t!("preferences.hide_avatar_description"))
.checked(hide_avatar)
Switch::new("hide_avatar")
.label("Hide user avatars")
.description("Unload all avatar pictures to improve performance and reduce memory usage.")
.checked(hide)
.on_click(move |_, _window, cx| {
AppSettings::update_hide_user_avatars(!hide_avatar, cx);
AppSettings::update_hide_user_avatars(!hide, cx);
}),
)
.child(
Switch::new("proxy_user_avatars")
.label(t!("preferences.proxy_avatars_label"))
.description(t!("preferences.proxy_description"))
.checked(proxy_avatar)
Switch::new("proxy_avatar")
.label("Proxy user avatars")
.description("Use wsrv.nl to resize and downscale avatar pictures (saves ~50MB of data).")
.checked(proxy)
.on_click(move |_, _window, cx| {
AppSettings::update_proxy_user_avatars(!proxy_avatar, cx);
AppSettings::update_proxy_user_avatars(!proxy, cx);
}),
),
),

View File

@@ -1,78 +1,91 @@
use common::display::{shorten_pubkey, DisplayProfile};
use common::nip05::nip05_verify;
use global::nostr_client;
use std::time::Duration;
use common::{nip05_verify, shorten_pubkey, RenderedProfile, RenderedTimestamp, BOOTSTRAP_RELAYS};
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, rems, App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render,
SharedString, Styled, Task, Window,
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
};
use gpui_tokio::Tokio;
use i18n::{shared_t, t};
use identity::Identity;
use nostr_sdk::prelude::*;
use registry::Registry;
use settings::AppSettings;
use person::{Person, PersonRegistry};
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
Screening::new(public_key, window, cx)
cx.new(|cx| Screening::new(public_key, window, cx))
}
pub struct Screening {
public_key: PublicKey,
profile: Person,
verified: bool,
followed: bool,
dm_relays: bool,
mutual_contacts: usize,
last_active: Option<Timestamp>,
mutual_contacts: Vec<Profile>,
_tasks: SmallVec<[Task<()>; 3]>,
}
impl Screening {
pub fn new(public_key: PublicKey, _window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|_| Self {
public_key,
verified: false,
followed: false,
dm_relays: false,
mutual_contacts: 0,
})
}
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Skip if user isn't logged in
let Some(identity) = Identity::read_global(cx).public_key() else {
return;
};
let public_key = self.public_key;
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
let check_trust_score: Task<(bool, usize, bool)> = cx.background_spawn(async move {
let client = nostr_client();
let mut tasks = smallvec![];
let follow = Filter::new()
.kind(Kind::ContactList)
.author(identity)
.pubkey(public_key)
.limit(1);
let contact_check: Task<Result<(bool, Vec<Profile>), Error>> = cx.background_spawn({
let client = nostr.read(cx).client();
async move {
let signer = client.signer().await?;
let signer_pubkey = signer.get_public_key().await?;
let contacts = Filter::new()
.kind(Kind::ContactList)
.pubkey(public_key)
.limit(1);
// Check if user is in contact list
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
let followed = contacts.unwrap_or_default().contains(&public_key);
let relays = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
// Check mutual contacts
let contact_list = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![];
let is_follow = client.database().count(follow).await.unwrap_or(0) >= 1;
let mutual_contacts = client.database().count(contacts).await.unwrap_or(0);
let dm_relays = client.database().count(relays).await.unwrap_or(0) >= 1;
if let Ok(events) = client.database().query(contact_list).await {
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
if let Ok(metadata) = client.database().metadata(event.pubkey).await {
let profile = Profile::new(event.pubkey, metadata.unwrap_or_default());
mutual_contacts.push(profile);
}
}
}
(is_follow, mutual_contacts, dm_relays)
Ok((followed, mutual_contacts))
}
});
let verify_nip05 = if let Some(address) = self.address(cx) {
let activity_check = cx.background_spawn(async move {
let filter = Filter::new().author(public_key).limit(1);
let mut activity: Option<Timestamp> = None;
if let Ok(mut stream) = client
.stream_events_from(BOOTSTRAP_RELAYS, filter, Duration::from_secs(2))
.await
{
while let Some((_url, event)) = stream.next().await {
if let Ok(event) = event {
activity = Some(event.created_at);
}
}
}
activity
});
let addr_check = if let Some(address) = profile.metadata().nip05 {
Some(Tokio::spawn(cx, async move {
nip05_verify(public_key, &address).await.unwrap_or(false)
}))
@@ -80,54 +93,79 @@ impl Screening {
None
};
cx.spawn_in(window, async move |this, cx| {
let (followed, mutual_contacts, dm_relays) = check_trust_score.await;
this.update(cx, |this, cx| {
this.followed = followed;
this.mutual_contacts = mutual_contacts;
this.dm_relays = dm_relays;
cx.notify();
})
.ok();
// Update the NIP05 verification status if user has NIP05 address
if let Some(task) = verify_nip05 {
if let Ok(verified) = task.await {
tasks.push(
// Run the contact check in the background
cx.spawn_in(window, async move |this, cx| {
if let Ok((followed, mutual_contacts)) = contact_check.await {
this.update(cx, |this, cx| {
this.verified = verified;
this.followed = followed;
this.mutual_contacts = mutual_contacts;
cx.notify();
})
.ok();
}
}
})
.detach();
}),
);
tasks.push(
// Run the activity check in the background
cx.spawn_in(window, async move |this, cx| {
let active = activity_check.await;
this.update(cx, |this, cx| {
this.last_active = active;
cx.notify();
})
.ok();
}),
);
tasks.push(
// Run the NIP-05 verification in the background
cx.spawn_in(window, async move |this, cx| {
if let Some(task) = addr_check {
if let Ok(verified) = task.await {
this.update(cx, |this, cx| {
this.verified = verified;
cx.notify();
})
.ok();
}
}
}),
);
Self {
profile,
verified: false,
followed: false,
last_active: None,
mutual_contacts: vec![],
_tasks: tasks,
}
}
fn profile(&self, cx: &Context<Self>) -> Profile {
let registry = Registry::read_global(cx);
registry.get_person(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<String> {
self.profile(cx).metadata().nip05
fn address(&self, _cx: &Context<Self>) -> Option<String> {
self.profile.metadata().nip05
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut App) {
let Ok(bech32) = self.public_key.to_bech32();
let Ok(bech32) = self.profile.public_key().to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}"));
}
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let public_key = self.public_key;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.profile.public_key();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let client = nostr_client();
let builder = EventBuilder::report(
vec![Tag::public_key_report(public_key, Report::Impersonation)],
"scam/impersonation",
);
let _ = client.send_event_builder(builder).await?;
let signer = client.signer().await?;
let tag = Tag::public_key_report(public_key, Report::Impersonation);
let event = EventBuilder::report(vec![tag], "").sign(&signer).await?;
// Send the report to the public relays
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
Ok(())
});
@@ -136,20 +174,59 @@ impl Screening {
if task.await.is_ok() {
cx.update(|window, cx| {
window.close_modal(cx);
window.push_notification(t!("screening.report_msg"), cx);
window.push_notification("Report submitted successfully", cx);
})
.ok();
}
})
.detach();
}
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let contacts = self.mutual_contacts.clone();
window.open_modal(cx, move |this, _window, _cx| {
let contacts = contacts.clone();
let total = contacts.len();
this.title(SharedString::from("Mutual contacts")).child(
v_flex().gap_1().pb_4().child(
uniform_list("contacts", total, move |range, _window, cx| {
let mut items = Vec::with_capacity(total);
for ix in range {
if let Some(contact) = contacts.get(ix) {
items.push(
h_flex()
.h_11()
.w_full()
.px_2()
.gap_1p5()
.rounded(cx.theme().radius)
.text_sm()
.hover(|this| {
this.bg(cx.theme().elevated_surface_background)
})
.child(Avatar::new(contact.avatar(true)).size(rems(1.75)))
.child(contact.display_name()),
);
}
}
items
})
.h(px(300.)),
),
)
});
}
}
impl Render for Screening {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let proxy = AppSettings::get_proxy_user_avatars(cx);
let profile = self.profile(cx);
let shorten_pubkey = shorten_pubkey(profile.public_key(), 8);
let shorten_pubkey = shorten_pubkey(self.profile.public_key(), 8);
let total_mutuals = self.mutual_contacts.len();
let last_active = self.last_active.map(|_| true);
v_flex()
.gap_4()
@@ -159,24 +236,22 @@ impl Render for Screening {
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(4.)))
.child(Avatar::new(self.profile.avatar()).size(rems(4.)))
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(profile.display_name()),
.child(self.profile.name()),
),
)
.child(
h_flex()
.gap_3()
.child(
div()
h_flex()
.p_1()
.flex_1()
.h_7()
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(cx.theme().surface_background)
@@ -192,20 +267,20 @@ impl Render for Screening {
.gap_1()
.child(
Button::new("njump")
.label(t!("profile.njump"))
.label("View on njump.me")
.secondary()
.small()
.rounded(ButtonRounded::Full)
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_njump(window, cx);
})),
)
.child(
Button::new("report")
.tooltip(t!("screening.report"))
.tooltip("Report as a scam or impostor")
.icon(IconName::Report)
.danger()
.rounded(ButtonRounded::Full)
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.report(window, cx);
})),
@@ -220,106 +295,160 @@ impl Render for Screening {
.items_start()
.gap_2()
.text_sm()
.child(status_badge(self.followed, cx))
.child(status_badge(Some(self.followed), cx))
.child(
v_flex()
.text_sm()
.child(shared_t!("screening.contact_label"))
.child(div().text_color(cx.theme().text_muted).child({
if self.followed {
shared_t!("screening.contact")
} else {
shared_t!("screening.not_contact")
}
})),
.child(SharedString::from("Contact"))
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from("This person is one of your contacts.")
} else {
SharedString::from("This person is not one of your contacts.")
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(self.verified, cx))
.text_sm()
.child(status_badge(last_active, cx))
.child(
v_flex()
.text_sm()
.child(
h_flex()
.gap_0p5()
.child(SharedString::from("Activity on Public Relays"))
.child(
Button::new("active")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.tooltip("This may be inaccurate if the user only publishes to their private relays."),
),
)
.child(
div()
.w_full()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.map(|this| {
if let Some(date) = self.last_active {
this.child(SharedString::from(format!(
"Last active: {}.",
date.to_human_time()
)))
} else {
this.child(SharedString::from("This person hasn't had any activity."))
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(Some(self.verified), cx))
.child(
v_flex()
.text_sm()
.child({
if let Some(addr) = self.address(cx) {
shared_t!("screening.nip05_addr", addr = addr)
SharedString::from(format!("{} validation", addr))
} else {
shared_t!("screening.nip05_label")
SharedString::from("Friendly Address (NIP-05) validation")
}
})
.child(div().text_color(cx.theme().text_muted).child({
if self.address(cx).is_some() {
if self.verified {
shared_t!("screening.nip05_ok")
} else {
shared_t!("screening.nip05_failed")
}
} else {
shared_t!("screening.nip05_empty")
}
})),
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.address(cx).is_some() {
if self.verified {
SharedString::from("The address matches the user's public key.")
} else {
SharedString::from("The address does not match the user's public key.")
}
} else {
SharedString::from("This person has not set up their friendly address")
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(self.mutual_contacts > 0, cx))
.child(status_badge(Some(total_mutuals > 0), cx))
.child(
v_flex()
.text_sm()
.child(shared_t!("screening.mutual_label"))
.child(div().text_color(cx.theme().text_muted).child({
if self.mutual_contacts > 0 {
shared_t!("screening.mutual", u = self.mutual_contacts)
} else {
shared_t!("screening.no_mutual")
}
})),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(self.dm_relays, cx))
.child(
v_flex()
.w_full()
.text_sm()
.child({
if self.dm_relays {
shared_t!("screening.relay_found")
} else {
shared_t!("screening.relay_empty")
}
})
.child(div().w_full().text_color(cx.theme().text_muted).child(
{
if self.dm_relays {
shared_t!("screening.relay_found_desc")
} else {
shared_t!("screening.relay_empty_desc")
}
},
)),
.child(
h_flex()
.gap_0p5()
.child(SharedString::from("Mutual contacts"))
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.on_click(cx.listener(
move |this, _, window, cx| {
this.mutual_contacts(window, cx);
},
)),
),
)
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if total_mutuals > 0 {
SharedString::from(format!(
"You have {} mutual contacts with this person.",
total_mutuals
))
} else {
SharedString::from("You don't have any mutual contacts with this person.")
}
}),
),
),
),
)
}
}
fn status_badge(status: bool, cx: &App) -> Div {
div()
.pt_1()
fn status_badge(status: Option<bool>, cx: &App) -> Div {
h_flex()
.size_6()
.justify_center()
.flex_shrink_0()
.child(Icon::new(IconName::CheckCircleFill).small().text_color({
if status {
cx.theme().icon_accent
.map(|this| {
if let Some(status) = status {
this.child(Icon::new(IconName::CheckCircleFill).small().text_color({
if status {
cx.theme().icon_accent
} else {
cx.theme().icon_muted
}
}))
} else {
cx.theme().icon_muted
this.child(Indicator::new().small())
}
}))
})
}

View File

@@ -0,0 +1,325 @@
use std::collections::HashSet;
use std::time::Duration;
use anyhow::{anyhow, Error};
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, Styled, Subscription, Task, TextAlign, UniformList,
Window,
};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<SetupRelay> {
cx.new(|cx| SetupRelay::new(window, cx))
}
#[derive(Debug)]
pub struct SetupRelay {
input: Entity<InputState>,
error: Option<SharedString>,
// All relays
relays: HashSet<RelayUrl>,
// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
// Background tasks
_tasks: SmallVec<[Task<()>; 1]>,
}
impl SetupRelay {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
let mut subscriptions = smallvec![];
let mut tasks = smallvec![];
tasks.push(
// Load user's relays in the local database
cx.spawn_in(window, async move |this, cx| {
let result = cx
.background_spawn(async move { Self::load(&client).await })
.await;
if let Ok(relays) = result {
this.update(cx, |this, cx| {
this.relays.extend(relays);
cx.notify();
})
.ok();
}
}),
);
subscriptions.push(
// Subscribe to user's input events
cx.subscribe_in(
&input,
window,
move |this: &mut Self, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add(window, cx);
}
},
),
);
Self {
input,
relays: HashSet::new(),
error: None,
_subscriptions: subscriptions,
_tasks: tasks,
}
}
async fn load(client: &Client) -> Result<Vec<RelayUrl>, Error> {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
let urls = nip17::extract_owned_relay_list(event).collect();
Ok(urls)
} else {
Err(anyhow!("Not found."))
}
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.input.read(cx).value().to_string();
if !value.starts_with("ws") {
self.set_error("Relay URl is invalid", window, cx);
return;
}
if let Ok(url) = RelayUrl::parse(&value) {
if !self.relays.insert(url) {
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
cx.notify();
}
} else {
self.set_error("Relay URl is invalid", window, cx);
}
}
fn remove(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
self.relays.remove(url);
cx.notify();
}
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
where
E: Into<SharedString>,
{
self.error = Some(error.into());
cx.notify();
// Clear the error message after a delay
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})
.ok();
})
.detach();
}
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.relays.is_empty() {
self.set_error(
"You need to add at least 1 relay to receive messages from others.",
window,
cx,
);
return;
};
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let relays = self.relays.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
let signer = client.signer().await?;
let tags: Vec<Tag> = relays
.iter()
.map(|relay| Tag::relay(relay.clone()))
.collect();
let event = EventBuilder::new(Kind::InboxRelays, "")
.tags(tags)
.sign(&signer)
.await?;
// Set messaging relays
client.send_event_to(urls, &event).await?;
// Connect to messaging relays
for relay in relays.iter() {
client.add_relay(relay).await.ok();
client.connect_relay(relay).await.ok();
}
Ok(())
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
window.close_modal(cx);
})
.ok();
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_error(e.to_string(), window, cx);
})
.ok();
}
};
})
.detach();
}
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
let relays = self.relays.clone();
let total = relays.len();
uniform_list(
"relays",
total,
cx.processor(move |_v, range, _window, cx| {
let mut items = Vec::new();
for ix in range {
if let Some(url) = relays.iter().nth(ix) {
items.push(
div()
.id(SharedString::from(url.to_string()))
.group("")
.w_full()
.h_9()
.py_0p5()
.child(
div()
.px_2()
.h_full()
.w_full()
.flex()
.items_center()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.text_xs()
.child(SharedString::from(url.to_string()))
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let url = url.to_owned();
cx.listener(move |this, _ev, _window, cx| {
this.remove(&url, cx);
})
}),
),
),
)
}
}
items
}),
)
.w_full()
.min_h(px(200.))
}
fn render_empty(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.h_20()
.mb_2()
.justify_center()
.text_sm()
.text_align(TextAlign::Center)
.child(SharedString::from("Please add some relays."))
}
}
impl Render for SetupRelay {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.text_sm()
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::from("In order to receive messages from others, you need to set up at least one Messaging Relay.")),
)
.child(
v_flex()
.gap_2()
.child(
h_flex()
.gap_1()
.w_full()
.child(TextInput::new(&self.input).small())
.child(
Button::new("add")
.icon(IconName::PlusFill)
.label("Add")
.ghost()
.on_click(cx.listener(move |this, _, window, cx| {
this.add(window, cx);
})),
),
)
.when_some(self.error.as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
)
.map(|this| {
if !self.relays.is_empty() {
this.child(self.render_list(window, cx))
} else {
this.child(self.render_empty(window, cx))
}
})
}
}

View File

@@ -1,737 +0,0 @@
use std::collections::BTreeSet;
use std::ops::Range;
use std::time::Duration;
use anyhow::{anyhow, Error};
use common::debounced_delay::DebouncedDelay;
use common::display::TextUtils;
use global::constants::{BOOTSTRAP_RELAYS, SEARCH_RELAYS};
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled,
Subscription, Task, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use itertools::Itertools;
use list_item::RoomListItem;
use nostr_sdk::prelude::*;
use registry::room::{Room, RoomKind};
use registry::{Registry, RegistrySignal};
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::popup_menu::PopupMenu;
use ui::{v_flex, ContextModal, IconName, Selectable, Sizable, StyledExt};
mod list_item;
const FIND_DELAY: u64 = 600;
const FIND_LIMIT: usize = 10;
const TOTAL_SKELETONS: usize = 3;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
Sidebar::new(window, cx)
}
pub struct Sidebar {
name: SharedString,
// Search
find_input: Entity<InputState>,
find_debouncer: DebouncedDelay<Self>,
finding: bool,
cancel_handle: Entity<Option<smol::channel::Sender<()>>>,
local_result: Entity<Option<Vec<Entity<Room>>>>,
global_result: Entity<Option<Vec<Entity<Room>>>>,
// Rooms
indicator: Entity<Option<RoomKind>>,
active_filter: Entity<RoomKind>,
// GPUI
focus_handle: FocusHandle,
image_cache: Entity<RetainAllImageCache>,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
}
impl Sidebar {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let active_filter = cx.new(|_| RoomKind::Ongoing);
let indicator = cx.new(|_| None);
let local_result = cx.new(|_| None);
let global_result = cx.new(|_| None);
let cancel_handle = cx.new(|_| None);
let find_input = cx.new(|cx| {
InputState::new(window, cx).placeholder(t!("sidebar.find_or_start_conversation"))
});
let chats = Registry::global(cx);
let mut subscriptions = smallvec![];
subscriptions.push(cx.subscribe_in(
&chats,
window,
move |this, _chats, event, _window, cx| {
if let RegistrySignal::NewRequest(kind) = event {
this.indicator.update(cx, |this, cx| {
*this = Some(kind.to_owned());
cx.notify();
});
}
},
));
subscriptions.push(cx.subscribe_in(
&find_input,
window,
|this, _state, event, window, cx| {
match event {
InputEvent::PressEnter { .. } => this.search(window, cx),
InputEvent::Change(text) => {
// Clear the result when input is empty
if text.is_empty() {
this.clear_search_results(window, cx);
} else {
// Run debounced search
this.find_debouncer.fire_new(
Duration::from_millis(FIND_DELAY),
window,
cx,
|this, window, cx| this.debounced_search(window, cx),
);
}
}
_ => {}
}
},
));
Self {
name: "Sidebar".into(),
focus_handle: cx.focus_handle(),
image_cache: RetainAllImageCache::new(cx),
find_debouncer: DebouncedDelay::new(),
finding: false,
cancel_handle,
indicator,
active_filter,
find_input,
local_result,
global_result,
subscriptions,
}
}
async fn request_metadata(client: &Client, public_key: PublicKey) -> Result<(), Error> {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList];
let filter = Filter::new().author(public_key).kinds(kinds).limit(10);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await?;
log::info!("Subscribe to get metadata for: {public_key}");
Ok(())
}
async fn create_temp_room(identity: PublicKey, public_key: PublicKey) -> Result<Room, Error> {
let client = nostr_client();
let keys = Keys::generate();
let builder = EventBuilder::private_msg_rumor(public_key, "");
let event = builder.build(identity).sign(&keys).await?;
// Request to get user's metadata
Self::request_metadata(client, public_key).await?;
// Create a temporary room
let room = Room::new(&event).rearrange_by(identity);
Ok(room)
}
async fn nip50(identity: PublicKey, query: &str) -> BTreeSet<Room> {
let client = nostr_client();
let timeout = Duration::from_secs(2);
let mut rooms: BTreeSet<Room> = BTreeSet::new();
let filter = Filter::new()
.kind(Kind::Metadata)
.search(query.to_lowercase())
.limit(FIND_LIMIT);
if let Ok(events) = client
.fetch_events_from(SEARCH_RELAYS, filter, timeout)
.await
{
// Process to verify the search results
for event in events.into_iter().unique_by(|event| event.pubkey) {
// Skip if author is match current user
if event.pubkey == identity {
continue;
}
// Return a temporary room
if let Ok(room) = Self::create_temp_room(identity, event.pubkey).await {
rooms.insert(room);
}
}
}
rooms
}
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
cx.spawn_in(window, async move |this, cx| {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.search(window, cx);
})
.ok();
})
.ok();
})
}
fn search_by_nip50(
&mut self,
query: &str,
rx: smol::channel::Receiver<()>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(identity) = Identity::read_global(cx).public_key() else {
// User is not logged in. Stop searching
self.set_finding(false, window, cx);
self.set_cancel_handle(None, cx);
return;
};
let query = query.to_owned();
let query_cloned = query.clone();
let task = smol::future::or(
Tokio::spawn(cx, async move {
let rooms = Self::nip50(identity, &query).await;
Some(rooms)
}),
Tokio::spawn(cx, async move {
let _ = rx.recv().await.is_ok();
None
}),
);
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(Some(results)) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
let msg = t!("sidebar.empty", query = query_cloned);
let rooms = results.into_iter().map(|r| cx.new(|_| r)).collect_vec();
if rooms.is_empty() {
window.push_notification(msg, cx);
}
this.results(rooms, true, window, cx);
})
.ok();
})
.ok();
}
// User cancelled the search
Ok(None) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_finding(false, window, cx);
this.set_cancel_handle(None, cx);
})
})
.ok();
}
// Async task failed
Err(e) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
window.push_notification(e.to_string(), cx);
this.set_finding(false, window, cx);
this.set_cancel_handle(None, cx);
})
})
.ok();
}
};
})
.detach();
}
fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(identity) = Identity::read_global(cx).public_key() else {
// User is not logged in. Stop searching
self.set_finding(false, window, cx);
self.set_cancel_handle(None, cx);
return;
};
let address = query.to_owned();
let task = Tokio::spawn(cx, async move {
if let Ok(profile) = common::nip05::nip05_profile(&address).await {
Self::create_temp_room(identity, profile.public_key).await
} else {
Err(anyhow!(t!("sidebar.addr_error")))
}
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(Ok(room)) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.results(vec![cx.new(|_| room)], true, window, cx);
})
.ok();
})
.ok();
}
Ok(Err(e)) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
window.push_notification(e.to_string(), cx);
this.set_cancel_handle(None, cx);
this.set_finding(false, window, cx);
})
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
window.push_notification(e.to_string(), cx);
this.set_cancel_handle(None, cx);
this.set_finding(false, window, cx);
})
})
.ok();
}
};
})
.detach();
}
fn search_by_pubkey(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
let Ok(public_key) = query.to_public_key() else {
window.push_notification(t!("common.pubkey_invalid"), cx);
self.set_finding(false, window, cx);
return;
};
let Some(identity) = Identity::read_global(cx).public_key() else {
// User is not logged in. Stop searching
self.set_finding(false, window, cx);
return;
};
let task: Task<Result<Room, Error>> = cx.background_spawn(async move {
// Create a gift wrap event to represent as room
Self::create_temp_room(identity, public_key).await
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(room) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
let registry = Registry::read_global(cx);
let result = registry.search_by_public_key(public_key, cx);
if !result.is_empty() {
this.results(result, false, window, cx);
} else {
this.results(vec![cx.new(|_| room)], true, window, cx);
}
})
.ok();
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
};
})
.detach();
}
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let (tx, rx) = smol::channel::bounded::<()>(1);
let tx_clone = tx.clone();
// Return if the query is empty
if self.find_input.read(cx).value().is_empty() {
return;
}
// Return if search is in progress
if self.finding {
if self.cancel_handle.read(cx).is_none() {
window.push_notification(t!("sidebar.search_in_progress"), cx);
return;
} else {
// This is a hack to cancel ongoing search request
cx.background_spawn(async move {
tx.send(()).await.ok();
})
.detach();
}
}
let input = self.find_input.read(cx).value();
let query = input.to_string();
// Block the input until the search process completes
self.set_finding(true, window, cx);
// Process to search by pubkey if query starts with npub or nprofile
if query.starts_with("npub1") || query.starts_with("nprofile1") {
self.search_by_pubkey(&query, window, cx);
return;
};
// Process to search by NIP05 if query is a valid NIP-05 identifier (name@domain.tld)
if query.split('@').count() == 2 {
let parts: Vec<&str> = query.split('@').collect();
if !parts[0].is_empty() && !parts[1].is_empty() && parts[1].contains('.') {
self.search_by_nip05(&query, window, cx);
return;
}
}
let chats = Registry::read_global(cx);
// Get all local results with current query
let local_results = chats.search(&query, cx);
if !local_results.is_empty() {
// Try to update with local results first
self.results(local_results, false, window, cx);
} else {
// If no local results, try global search via NIP-50
self.set_cancel_handle(Some(tx_clone), cx);
self.search_by_nip50(&query, rx, window, cx);
}
}
fn results(
&mut self,
rooms: Vec<Entity<Room>>,
global: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.finding {
self.set_finding(false, window, cx);
}
if self.cancel_handle.read(cx).is_some() {
self.set_cancel_handle(None, cx);
}
if !rooms.is_empty() {
if global {
self.global_result.update(cx, |this, cx| {
*this = Some(rooms);
cx.notify();
});
} else {
self.local_result.update(cx, |this, cx| {
*this = Some(rooms);
cx.notify();
});
}
}
}
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
self.finding = status;
// Disable the input to prevent duplicate requests
self.find_input.update(cx, |this, cx| {
this.set_disabled(status, cx);
this.set_loading(status, cx);
});
cx.notify();
}
fn set_cancel_handle(
&mut self,
handle: Option<smol::channel::Sender<()>>,
cx: &mut Context<Self>,
) {
self.cancel_handle.update(cx, |this, cx| {
*this = handle;
cx.notify();
});
}
fn clear_search_results(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Reset the input state
if self.finding {
self.set_finding(false, window, cx);
}
// Clear all local results
self.local_result.update(cx, |this, cx| {
*this = None;
cx.notify();
});
// Clear all global results
self.global_result.update(cx, |this, cx| {
*this = None;
cx.notify();
});
}
fn filter(&self, kind: &RoomKind, cx: &Context<Self>) -> bool {
self.active_filter.read(cx) == kind
}
fn set_filter(&mut self, kind: RoomKind, cx: &mut Context<Self>) {
self.indicator.update(cx, |this, cx| {
*this = None;
cx.notify();
});
self.active_filter.update(cx, |this, cx| {
*this = kind;
cx.notify();
});
}
fn open_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
let room = if let Some(room) = Registry::read_global(cx).room(&id, cx) {
room
} else {
let Some(result) = self.global_result.read(cx).as_ref() else {
window.push_notification(t!("common.room_error"), cx);
return;
};
let Some(room) = result.iter().find(|this| this.read(cx).id == id).cloned() else {
window.push_notification(t!("common.room_error"), cx);
return;
};
// Clear all search results
self.clear_search_results(window, cx);
room
};
Registry::global(cx).update(cx, |this, cx| {
this.push_room(room, cx);
});
}
fn list_items(
&self,
rooms: &[Entity<Room>],
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement> {
let proxy = AppSettings::get_proxy_user_avatars(cx);
let mut items = Vec::with_capacity(range.end - range.start);
for ix in range {
if let Some(room) = rooms.get(ix) {
let this = room.read(cx);
let room_id = this.id;
let handler = cx.listener({
move |this, _, window, cx| {
this.open_room(room_id, window, cx);
}
});
items.push(
RoomListItem::new(ix)
.room_id(room_id)
.name(this.display_name(cx))
.avatar(this.display_image(proxy, cx))
.created_at(this.ago())
.public_key(this.members[0])
.kind(this.kind)
.on_click(handler),
)
} else {
items.push(RoomListItem::new(ix));
}
}
items
}
}
impl Panel for Sidebar {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Sidebar {}
impl Focusable for Sidebar {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let registry = Registry::read_global(cx);
let loading = registry.loading;
// Get rooms from either search results or the chat registry
let rooms = if let Some(results) = self.local_result.read(cx).as_ref() {
results.to_owned()
} else if let Some(results) = self.global_result.read(cx).as_ref() {
results.to_owned()
} else {
#[allow(clippy::collapsible_else_if)]
if self.active_filter.read(cx) == &RoomKind::Ongoing {
registry.ongoing_rooms(cx)
} else {
registry.request_rooms(cx)
}
};
// Get total rooms count
let mut total_rooms = rooms.len();
// If loading in progress
// Add 3 skeletons to the room list
if loading {
total_rooms += TOTAL_SKELETONS;
}
v_flex()
.image_cache(self.image_cache.clone())
.size_full()
.relative()
.gap_3()
// Search Input
.child(
div()
.relative()
.mt_3()
.px_2p5()
.w_full()
.h_7()
.flex_none()
.flex()
.child(
TextInput::new(&self.find_input)
.small()
.cleanable()
.appearance(true)
.suffix(
Button::new("find")
.icon(IconName::Search)
.tooltip(t!("sidebar.press_enter_to_search"))
.transparent()
.small(),
),
),
)
// Chat Rooms
.child(
v_flex()
.gap_1()
.flex_1()
.px_1p5()
.w_full()
.overflow_y_hidden()
.child(
div()
.px_1()
.h_flex()
.gap_2()
.flex_none()
.child(
Button::new("all")
.label(t!("sidebar.all_button"))
.tooltip(t!("sidebar.all_conversations_tooltip"))
.when_some(self.indicator.read(cx).as_ref(), |this, kind| {
this.when(kind == &RoomKind::Ongoing, |this| {
this.child(
div().size_1().rounded_full().bg(cx.theme().cursor),
)
})
})
.small()
.bold()
.secondary()
.rounded(ButtonRounded::Full)
.selected(self.filter(&RoomKind::Ongoing, cx))
.on_click(cx.listener(|this, _, _, cx| {
this.set_filter(RoomKind::Ongoing, cx);
})),
)
.child(
Button::new("requests")
.label(t!("sidebar.requests_button"))
.tooltip(t!("sidebar.requests_tooltip"))
.when_some(self.indicator.read(cx).as_ref(), |this, kind| {
this.when(kind != &RoomKind::Ongoing, |this| {
this.child(
div().size_1().rounded_full().bg(cx.theme().cursor),
)
})
})
.small()
.bold()
.secondary()
.rounded(ButtonRounded::Full)
.selected(!self.filter(&RoomKind::Ongoing, cx))
.on_click(cx.listener(|this, _, _, cx| {
this.set_filter(RoomKind::default(), cx);
})),
),
)
.child(
uniform_list(
"rooms",
total_rooms,
cx.processor(move |this, range, _window, cx| {
this.list_items(&rooms, range, cx)
}),
)
.h_full(),
),
)
}
}

View File

@@ -1,131 +1,319 @@
use gpui::prelude::FluentBuilder;
use gpui::{
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use i18n::t;
use identity::Identity;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::popup_menu::PopupMenu;
use ui::{Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Startup> {
Startup::new(window, cx)
}
pub struct Startup {
name: SharedString,
focus_handle: FocusHandle,
}
impl Startup {
fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self {
name: "Startup".into(),
focus_handle: cx.focus_handle(),
})
}
}
impl Panel for Startup {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Startup {}
impl Focusable for Startup {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Startup {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
let identity = Identity::global(cx);
let logging_in = identity.read(cx).logging_in();
div()
.relative()
.size_full()
.flex()
.items_center()
.justify_center()
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_center()
.gap_6()
.child(
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().elevated_surface_background),
)
.child(
div()
.w_24()
.flex()
.items_center()
.justify_center()
.gap_2()
.when(logging_in, |this| {
this.child(
div().text_sm().text_color(cx.theme().text).child(
SharedString::new(t!("startup.auto_login_in_progress")),
),
)
})
.child(Indicator::new().small()),
),
)
.child(
div().absolute().bottom_3().right_3().child(
div()
.flex()
.items_center()
.justify_end()
.gap_1p5()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("startup.stuck"))),
)
.child(
Button::new("reset")
.label(SharedString::new(t!("startup.reset")))
.small()
.ghost()
.on_click(|_, window, cx| {
Identity::global(cx).update(cx, |this, cx| {
this.unload(window, cx);
// Restart application
cx.restart(None);
});
}),
),
),
)
}
}
use std::time::Duration;
use common::BUNKER_TIMEOUT;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
Window,
};
use key_store::{Credential, KeyItem, KeyStore};
use nostr_connect::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::{h_flex, v_flex, ContextModal, Sizable, StyledExt};
use crate::actions::{reset, CoopAuthUrlHandler};
pub fn init(cre: Credential, window: &mut Window, cx: &mut App) -> Entity<Startup> {
cx.new(|cx| Startup::new(cre, window, cx))
}
/// Startup
#[derive(Debug)]
pub struct Startup {
name: SharedString,
focus_handle: FocusHandle,
/// Local user credentials
credential: Credential,
/// Whether the loadng is in progress
loading: bool,
/// Image cache
image_cache: Entity<RetainAllImageCache>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
_tasks: SmallVec<[Task<()>; 1]>,
}
impl Startup {
fn new(credential: Credential, window: &mut Window, cx: &mut Context<Self>) -> Self {
let tasks = smallvec![];
let mut subscriptions = smallvec![];
subscriptions.push(
// Clear the local state when user closes the account panel
cx.on_release_in(window, move |this, window, cx| {
this.image_cache.update(cx, |this, cx| {
this.clear(window, cx);
});
}),
);
Self {
credential,
loading: false,
name: "Onboarding".into(),
focus_handle: cx.focus_handle(),
image_cache: RetainAllImageCache::new(cx),
_subscriptions: subscriptions,
_tasks: tasks,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_loading(true, cx);
let secret = self.credential.secret();
// Try to login with bunker
if secret.starts_with("bunker://") {
match NostrConnectUri::parse(secret) {
Ok(uri) => {
self.login_with_bunker(uri, window, cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
self.set_loading(false, cx);
}
}
return;
};
// Fall back to login with keys
match SecretKey::parse(secret) {
Ok(secret) => {
self.login_with_keys(secret, cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
self.set_loading(false, cx);
}
}
}
fn login_with_bunker(
&mut self,
uri: NostrConnectUri,
window: &mut Window,
cx: &mut Context<Self>,
) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let keystore = KeyStore::global(cx).read(cx).backend();
// Handle connection in the background
cx.spawn_in(window, async move |this, cx| {
let result = keystore
.read_credentials(&KeyItem::Bunker.to_string(), cx)
.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(Some((_, content))) => {
let secret = SecretKey::from_slice(&content).unwrap();
let keys = Keys::new(secret);
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
let mut signer = NostrConnect::new(uri, keys, timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Connect to the remote signer
this._tasks.push(
// Handle connection in the background
cx.spawn_in(window, async move |this, cx| {
match signer.bunker_uri().await {
Ok(_) => {
client.set_signer(signer).await;
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
window.push_notification(e.to_string(), cx);
this.set_loading(false, cx);
})
.ok();
}
}
}),
)
}
Ok(None) => {
window.push_notification(
"You must allow Coop access to the keyring to continue.",
cx,
);
this.set_loading(false, cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
this.set_loading(false, cx);
}
};
})
.ok();
})
.detach();
}
fn login_with_keys(&mut self, secret: SecretKey, cx: &mut Context<Self>) {
let keys = Keys::new(secret);
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.set_signer(keys, cx);
})
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
}
}
impl Panel for Startup {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for Startup {}
impl Focusable for Startup {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Startup {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
let persons = PersonRegistry::global(cx);
let bunker = self.credential.secret().starts_with("bunker://");
let profile = persons.read(cx).get(&self.credential.public_key(), cx);
v_flex()
.image_cache(self.image_cache.clone())
.relative()
.size_full()
.gap_10()
.items_center()
.justify_center()
.child(
v_flex()
.items_center()
.justify_center()
.gap_4()
.child(
svg()
.path("brand/coop.svg")
.size_16()
.text_color(cx.theme().elevated_surface_background),
)
.child(
div()
.text_center()
.child(
div()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::from("Welcome to Coop")),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::from(
"Chat Freely, Stay Private on Nostr.",
)),
),
),
)
.child(
v_flex()
.gap_2()
.child(
div()
.id("account")
.h_10()
.w_72()
.bg(cx.theme().elevated_surface_background)
.rounded(cx.theme().radius_lg)
.text_sm()
.when(self.loading, |this| {
this.child(
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(Indicator::new().small()),
)
})
.when(!self.loading, |this| {
let avatar = profile.avatar();
let name = profile.name();
this.child(
h_flex()
.h_full()
.justify_center()
.gap_2()
.child(
h_flex()
.gap_1()
.child(Avatar::new(avatar).size(rems(1.5)))
.child(div().pb_px().font_semibold().child(name)),
)
.child(div().when(bunker, |this| {
let label = SharedString::from("Nostr Connect");
this.child(
div()
.py_0p5()
.px_2()
.text_xs()
.bg(cx.theme().secondary_active)
.text_color(cx.theme().secondary_foreground)
.rounded_full()
.child(label),
)
})),
)
})
.text_color(cx.theme().text)
.active(|this| {
this.text_color(cx.theme().element_foreground)
.bg(cx.theme().element_active)
})
.hover(|this| {
this.text_color(cx.theme().element_foreground)
.bg(cx.theme().element_hover)
})
.on_click(cx.listener(move |this, _e, window, cx| {
this.login(window, cx);
})),
)
.child(Button::new("logout").label("Sign out").ghost().on_click(
|_, _window, cx| {
reset(cx);
},
)),
)
}
}

View File

@@ -1,12 +1,11 @@
use gpui::{
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Window,
};
use theme::ActiveTheme;
use ui::button::Button;
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::popup_menu::PopupMenu;
use ui::StyledExt;
use ui::{v_flex, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
Welcome::new(window, cx)
@@ -14,8 +13,7 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
pub struct Welcome {
name: SharedString,
closable: bool,
zoomable: bool,
version: SharedString,
focus_handle: FocusHandle,
}
@@ -25,10 +23,11 @@ impl Welcome {
}
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
let version = SharedString::from(format!("Version: {}", env!("CARGO_PKG_VERSION")));
Self {
version,
name: "Welcome".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
@@ -39,24 +38,15 @@ impl Panel for Welcome {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
"👋".into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
fn title(&self, cx: &App) -> AnyElement {
div()
.child(
svg()
.path("brand/coop.svg")
.size_4()
.text_color(cx.theme().element_background),
)
.into_any_element()
}
}
@@ -69,18 +59,17 @@ impl Focusable for Welcome {
}
impl Render for Welcome {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(
div()
.flex()
.flex_col()
v_flex()
.gap_2()
.items_center()
.gap_1()
.justify_center()
.child(
svg()
.path("brand/coop.svg")
@@ -88,11 +77,26 @@ impl Render for Welcome {
.text_color(cx.theme().elevated_surface_background),
)
.child(
div()
.child("coop on nostr")
.text_color(cx.theme().text_placeholder)
.font_semibold()
.text_sm(),
v_flex()
.items_center()
.justify_center()
.text_center()
.child(
div()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("coop on nostr")),
)
.child(
div()
.id("version")
.text_color(cx.theme().text_placeholder)
.text_xs()
.child(self.version.clone())
.on_click(|_, _window, cx| {
cx.open_url("https://github.com/lumehq/coop/releases");
}),
),
),
)
}

21
crates/device/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "device"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
state = { path = "../state" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
flume.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -0,0 +1,62 @@
use gpui::SharedString;
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum DeviceState {
#[default]
Initial,
Requesting,
Set,
}
/// Announcement
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Announcement {
/// The public key of the device that created this announcement.
public_key: PublicKey,
/// The name of the device that created this announcement.
client_name: Option<String>,
}
impl From<&Event> for Announcement {
fn from(val: &Event) -> Self {
let public_key = val
.tags
.iter()
.find(|tag| tag.kind().as_str() == "n")
.and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())
.unwrap_or(val.pubkey);
let client_name = val
.tags
.find(TagKind::Client)
.and_then(|tag| tag.content())
.map(|c| c.to_string());
Self::new(public_key, client_name)
}
}
impl Announcement {
pub fn new(public_key: PublicKey, client_name: Option<String>) -> Self {
Self {
public_key,
client_name,
}
}
/// Returns the public key of the device that created this announcement.
pub fn public_key(&self) -> PublicKey {
self.public_key
}
/// Returns the client name of the device that created this announcement.
pub fn client_name(&self) -> SharedString {
self.client_name
.as_ref()
.map(SharedString::from)
.unwrap_or(SharedString::from("Unknown"))
}
}

632
crates/device/src/lib.rs Normal file
View File

@@ -0,0 +1,632 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error};
use common::app_name;
pub use device::*;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, RelayState, GIFTWRAP_SUBSCRIPTION, TIMEOUT};
mod device;
const IDENTIFIER: &str = "coop:device";
pub fn init(cx: &mut App) {
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
}
struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
impl Global for GlobalDeviceRegistry {}
/// Device Registry
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
#[derive(Debug)]
pub struct DeviceRegistry {
/// Device signer
pub device_signer: Entity<Option<Arc<dyn NostrSigner>>>,
/// Device requests
requests: Entity<HashSet<Event>>,
/// Device state
state: DeviceState,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl DeviceRegistry {
/// Retrieve the global device registry state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalDeviceRegistry>().0.clone()
}
/// Set the global device registry instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalDeviceRegistry(state));
}
/// Create a new device registry instance
fn new(cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let identity = nostr.read(cx).identity();
let device_signer = cx.new(|_| None);
let requests = cx.new(|_| HashSet::default());
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Event>(100);
let mut subscriptions = smallvec![];
let mut tasks = vec![];
subscriptions.push(
// Observe the identity entity
cx.observe(&identity, |this, state, cx| {
if state.read(cx).has_public_key() {
if state.read(cx).relay_list_state() == RelayState::Set {
this.get_announcement(cx);
}
if state.read(cx).messaging_relays_state() == RelayState::Set {
this.get_messages(cx);
}
}
}),
);
tasks.push(
// Handle nostr notifications
cx.background_spawn(async move { Self::handle_notifications(&client, &tx).await }),
);
tasks.push(
// Update GPUI states
cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await {
match event.kind {
Kind::Custom(4454) => {
this.update(cx, |this, cx| {
this.add_request(event, cx);
})?;
}
Kind::Custom(4455) => {
this.update(cx, |this, cx| {
this.parse_response(event, cx);
})?;
}
_ => {}
}
}
Ok(())
}),
);
Self {
device_signer,
requests,
state: DeviceState::default(),
tasks,
_subscriptions: subscriptions,
}
}
/// Handle nostr notifications
async fn handle_notifications(client: &Client, tx: &flume::Sender<Event>) -> Result<(), Error> {
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
while let Ok(notification) = notifications.recv().await {
if let RelayPoolNotification::Message {
message: RelayMessage::Event { event, .. },
..
} = notification
{
if !processed_events.insert(event.id) {
// Skip if the event has already been processed
continue;
}
match event.kind {
Kind::Custom(4454) => {
if Self::verify_author(client, event.as_ref()).await {
tx.send_async(event.into_owned()).await.ok();
}
}
Kind::Custom(4455) => {
if Self::verify_author(client, event.as_ref()).await {
tx.send_async(event.into_owned()).await.ok();
}
}
_ => {}
}
}
}
Ok(())
}
/// Verify the author of an event
async fn verify_author(client: &Client, event: &Event) -> bool {
if let Ok(signer) = client.signer().await {
if let Ok(public_key) = signer.get_public_key().await {
return public_key == event.pubkey;
}
}
false
}
/// Encrypt and store device keys in the local database.
async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
// Encrypt the value
let content = signer.nip44_encrypt(&public_key, secret).await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.build(public_key)
.sign(&Keys::generate())
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Get device keys from the local database.
async fn get_keys(client: &Client) -> Result<Keys, Error> {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER);
if let Some(event) = client.database().query(filter).await?.first() {
let content = signer.nip44_decrypt(&public_key, &event.content).await?;
let secret = SecretKey::parse(&content)?;
let keys = Keys::new(secret);
Ok(keys)
} else {
Err(anyhow!("Key not found"))
}
}
/// Returns the device signer entity
pub fn signer(&self, cx: &App) -> Option<Arc<dyn NostrSigner>> {
self.device_signer.read(cx).clone()
}
/// Set the decoupled encryption key for the current user
fn set_device_signer<S>(&mut self, signer: S, cx: &mut Context<Self>)
where
S: NostrSigner + 'static,
{
self.set_state(DeviceState::Set, cx);
self.device_signer.update(cx, |this, cx| {
*this = Some(Arc::new(signer));
cx.notify();
});
}
/// Set the device state
fn set_state(&mut self, state: DeviceState, cx: &mut Context<Self>) {
self.state = state;
cx.notify();
}
/// Add a request for device keys
fn add_request(&mut self, request: Event, cx: &mut Context<Self>) {
self.requests.update(cx, |this, cx| {
this.insert(request);
cx.notify();
});
}
/// Continuously get gift wrap events for the current user in their messaging relays
fn get_messages(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let device_signer = self.device_signer.read(cx).clone();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let messaging_relays = nostr.read(cx).messaging_relays(&public_key, cx);
cx.background_spawn(async move {
let urls = messaging_relays.await;
let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
let mut filters = vec![];
// Construct a filter to get user messages
filters.push(Filter::new().kind(Kind::GiftWrap).pubkey(public_key));
// Construct a filter to get dekey messages if available
if let Some(signer) = device_signer.as_ref() {
if let Ok(pubkey) = signer.get_public_key().await {
filters.push(Filter::new().kind(Kind::GiftWrap).pubkey(pubkey));
}
}
if let Err(e) = client.subscribe_with_id_to(urls, id, filters, None).await {
log::error!("Failed to subscribe to gift wrap events: {e}");
}
})
.detach();
}
/// Get device announcement for current user
fn get_announcement(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let task: Task<Result<Event, Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
// Construct the filter for the device announcement event
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(public_key)
.limit(1);
let mut stream = client
.stream_events_from(&urls, vec![filter], Duration::from_secs(TIMEOUT))
.await?;
while let Some((_url, res)) = stream.next().await {
match res {
Ok(event) => {
log::info!("Received device announcement event: {event:?}");
return Ok(event);
}
Err(e) => {
log::error!("Failed to receive device announcement event: {e}");
}
}
}
Err(anyhow!("Device announcement not found"))
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(event) => {
this.update(cx, |this, cx| {
this.init_device_signer(&event, cx);
})?;
}
Err(_) => {
this.update(cx, |this, cx| {
this.announce_device(cx);
})?;
}
}
Ok(())
}));
}
/// Create a new device signer and announce it
fn announce_device(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let keys = Keys::generate();
let secret = keys.secret_key().to_secret_hex();
let n = keys.public_key();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let urls = write_relays.await;
// Construct an announcement event
let event = EventBuilder::new(Kind::Custom(10044), "")
.tags(vec![
Tag::custom(TagKind::custom("n"), vec![n]),
Tag::client(app_name()),
])
.sign(&signer)
.await?;
// Publish announcement
client.send_event_to(&urls, &event).await?;
// Save device keys to the database
Self::set_keys(&client, &secret).await?;
Ok(())
});
cx.spawn(async move |this, cx| {
if task.await.is_ok() {
this.update(cx, |this, cx| {
this.set_device_signer(keys, cx);
this.listen_device_request(cx);
})
.ok();
}
})
.detach();
}
/// Initialize device signer (decoupled encryption key) for the current user
fn init_device_signer(&mut self, event: &Event, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let announcement = Announcement::from(event);
let device_pubkey = announcement.public_key();
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
if let Ok(keys) = Self::get_keys(&client).await {
if keys.public_key() != device_pubkey {
return Err(anyhow!("Key mismatch"));
};
Ok(keys)
} else {
Err(anyhow!("Key not found"))
}
});
cx.spawn(async move |this, cx| {
match task.await {
Ok(keys) => {
this.update(cx, |this, cx| {
this.set_device_signer(keys, cx);
this.listen_device_request(cx);
})
.ok();
}
Err(e) => {
this.update(cx, |this, cx| {
this.request_device_keys(cx);
this.listen_device_approval(cx);
})
.ok();
log::warn!("Failed to initialize device signer: {e}");
}
};
})
.detach();
}
/// Listen for device key requests on user's write relays
fn listen_device_request(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
// Construct a filter for device key requests
let filter = Filter::new()
.kind(Kind::Custom(4454))
.author(public_key)
.since(Timestamp::now());
// Subscribe to the device key requests on user's write relays
client.subscribe_to(&urls, vec![filter], None).await?;
Ok(())
});
task.detach();
}
/// Listen for device key approvals on user's write relays
fn listen_device_approval(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
// Construct a filter for device key requests
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
.since(Timestamp::now());
// Subscribe to the device key requests on user's write relays
client.subscribe_to(&urls, vec![filter], None).await?;
Ok(())
});
task.detach();
}
/// Request encryption keys from other device
fn request_device_keys(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let app_keys = nostr.read(cx).app_keys().clone();
let app_pubkey = app_keys.public_key();
let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
.pubkey(app_pubkey)
.limit(1);
match client.database().query(filter).await?.first_owned() {
Some(event) => {
let root_device = event
.tags
.find(TagKind::custom("P"))
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let payload = event.content.as_str();
let decrypted = app_keys.nip44_decrypt(&root_device, payload).await?;
let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret);
Ok(Some(keys))
}
None => {
let urls = write_relays.await;
// Construct an event for device key request
let event = EventBuilder::new(Kind::Custom(4454), "")
.tags(vec![
Tag::client(app_name()),
Tag::custom(TagKind::custom("P"), vec![app_pubkey]),
])
.sign(&signer)
.await?;
// Send the event to write relays
client.send_event_to(&urls, &event).await?;
Ok(None)
}
}
});
cx.spawn(async move |this, cx| {
match task.await {
Ok(Some(keys)) => {
this.update(cx, |this, cx| {
this.set_device_signer(keys, cx);
})
.ok();
}
Ok(None) => {
this.update(cx, |this, cx| {
this.set_state(DeviceState::Requesting, cx);
})
.ok();
}
Err(e) => {
log::error!("Failed to request the encryption key: {e}");
}
};
})
.detach();
}
/// Parse the response event for device keys from other devices
fn parse_response(&mut self, event: Event, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys().clone();
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
let root_device = event
.tags
.find(TagKind::custom("P"))
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let payload = event.content.as_str();
let decrypted = app_keys.nip44_decrypt(&root_device, payload).await?;
let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret);
Ok(keys)
});
cx.spawn(async move |this, cx| {
match task.await {
Ok(keys) => {
this.update(cx, |this, cx| {
this.set_device_signer(keys, cx);
})
.ok();
}
Err(e) => {
log::error!("Error: {e}")
}
};
})
.detach();
}
/// Approve requests for device keys from other devices
#[allow(dead_code)]
fn approve(&mut self, event: Event, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
let signer = client.signer().await?;
// Get device keys
let keys = Self::get_keys(&client).await?;
let secret = keys.secret_key().to_secret_hex();
// Extract the target public key from the event tags
let target = event
.tags
.find(TagKind::custom("P"))
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Target is not a valid public key")?;
// Encrypt the device keys with the user's signer
let payload = signer.nip44_encrypt(&target, &secret).await?;
// Construct the response event
//
// P tag: the current device's public key
// p tag: the requester's public key
let event = EventBuilder::new(Kind::Custom(4455), payload)
.tags(vec![
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
Tag::public_key(target),
])
.sign(&signer)
.await?;
// Send the response event to the user's relay list
client.send_event_to(&urls, &event).await?;
Ok(())
});
task.detach();
}
}

View File

@@ -1,57 +0,0 @@
pub const APP_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop";
pub const APP_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc4MkNFRkQ2RkVGQURGNzUKUldSMTMvcisxdThzZUZraHc4Vno3NVNJek81VkJFUEV3MkJweGFxQXhpekdSU1JIekpqMG4yemMK";
pub const APP_UPDATER_ENDPOINT: &str = "https://coop-updater.reya.su/";
pub const KEYRING_URL: &str = "Coop Safe Storage";
pub const ACCOUNT_D: &str = "coop:account";
pub const SETTINGS_D: &str = "coop:settings";
/// Bootstrap Relays.
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://user.kindpag.es",
"wss://purplepag.es",
];
/// Search Relays.
pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.nostr.band"];
/// NIP65 Relays. Used for new account
pub const NIP65_RELAYS: [&str; 4] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nostr.net",
"wss://nos.lol",
];
/// Messaging Relays. Used for new account
pub const NIP17_RELAYS: [&str; 2] = ["wss://nip17.com", "wss://relay.0xchat.com"];
/// Default relay for Nostr Connect
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
/// Default timeout (in seconds) for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Total metadata requests will be grouped.
pub const METADATA_BATCH_LIMIT: usize = 100;
/// Maximum timeout for grouping metadata requests. (milliseconds)
pub const METADATA_BATCH_TIMEOUT: u64 = 300;
/// Maximum timeout for waiting for finish (seconds)
pub const WAIT_FOR_FINISH: u64 = 60;
/// Default width for all modals.
pub const DEFAULT_MODAL_WIDTH: f32 = 420.;
/// Default width of the sidebar.
pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.;
/// Image Resize Service
pub const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl";
/// Default NIP96 Media Server.
pub const NIP96_SERVER: &str = "https://nostrmedia.com";

View File

@@ -1,87 +0,0 @@
use std::collections::BTreeSet;
use std::sync::OnceLock;
use std::time::Duration;
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use paths::nostr_file;
use smol::lock::RwLock;
use crate::paths::support_dir;
pub mod constants;
pub mod paths;
/// Signals sent through the global event channel to notify UI components
#[derive(Debug, Clone)]
pub enum NostrSignal {
/// Received a new metadata event from Relay Pool
Metadata(Event),
/// Received a new gift wrap event from Relay Pool
GiftWrap(Event),
/// Finished processing all gift wrap events
Finish,
/// Partially finished processing all gift wrap events
PartialFinish,
/// DM relays have been found
DmRelaysFound,
/// Notice from Relay Pool
Notice(String),
}
static NOSTR_CLIENT: OnceLock<Client> = OnceLock::new();
static PROCESSED_EVENTS: OnceLock<RwLock<BTreeSet<EventId>>> = OnceLock::new();
static CURRENT_TIMESTAMP: OnceLock<Timestamp> = OnceLock::new();
static FIRST_RUN: OnceLock<bool> = OnceLock::new();
pub fn nostr_client() -> &'static Client {
NOSTR_CLIENT.get_or_init(|| {
// rustls uses the `aws_lc_rs` provider by default
// This only errors if the default provider has already
// been installed. We can ignore this `Result`.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.ok();
let lmdb = NostrLMDB::open(nostr_file()).expect("Database is NOT initialized");
let opts = ClientOptions::new()
.gossip(true)
.automatic_authentication(true)
.verify_subscriptions(false)
// Sleep after idle for 30 seconds
.sleep_when_idle(SleepWhenIdle::Enabled {
timeout: Duration::from_secs(30),
});
ClientBuilder::default().database(lmdb).opts(opts).build()
})
}
pub fn processed_events() -> &'static RwLock<BTreeSet<EventId>> {
PROCESSED_EVENTS.get_or_init(|| RwLock::new(BTreeSet::new()))
}
pub fn starting_time() -> &'static Timestamp {
CURRENT_TIMESTAMP.get_or_init(Timestamp::now)
}
pub fn first_run() -> &'static bool {
FIRST_RUN.get_or_init(|| {
let flag = support_dir().join(format!(".{}-first_run", env!("CARGO_PKG_VERSION")));
if !flag.exists() {
if std::fs::write(&flag, "").is_err() {
return false;
}
true // First run
} else {
false // Not first run
}
})
}

View File

@@ -1,8 +0,0 @@
[package]
name = "i18n"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
rust-i18n.workspace = true

View File

@@ -1,39 +0,0 @@
use rust_i18n::Backend;
rust_i18n::i18n!("../../locales");
pub struct I18nBackend;
impl Backend for I18nBackend {
fn available_locales(&self) -> Vec<&str> {
_RUST_I18N_BACKEND.available_locales()
}
fn translate(&self, locale: &str, key: &str) -> Option<&str> {
let val = _RUST_I18N_BACKEND.translate(locale, key);
if val.is_none() {
_RUST_I18N_BACKEND.translate("en", key)
} else {
val
}
}
}
#[macro_export]
macro_rules! init {
() => {
rust_i18n::i18n!(backend = i18n::I18nBackend);
};
}
#[macro_export]
macro_rules! shared_t {
($key:expr) => {
SharedString::new(t!($key))
};
($key:expr, $($param:ident = $value:expr),+) => {
SharedString::new(t!($key, $($param = $value),+))
};
}
pub use rust_i18n::{set_locale, t};

View File

@@ -1,617 +0,0 @@
use std::time::Duration;
use anyhow::{anyhow, Error};
use client_keys::ClientKeys;
use common::handle_auth::CoopAuthUrlHandler;
use global::constants::{ACCOUNT_D, NIP17_RELAYS, NIP65_RELAYS, NOSTR_CONNECT_TIMEOUT};
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, red, App, AppContext, Context, Entity, Global, ParentElement, SharedString, Styled,
Subscription, Task, WeakEntity, Window,
};
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use ui::input::{InputState, TextInput};
use ui::notification::Notification;
use ui::{ContextModal, Sizable};
pub fn init(window: &mut Window, cx: &mut App) {
Identity::set_global(cx.new(|cx| Identity::new(window, cx)), cx);
}
struct GlobalIdentity(Entity<Identity>);
impl Global for GlobalIdentity {}
pub struct Identity {
public_key: Option<PublicKey>,
logging_in: bool,
has_dm_relays: Option<bool>,
need_backup: Option<Keys>,
need_onboarding: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl Identity {
/// Retrieve the Global Identity instance
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalIdentity>().0.clone()
}
/// Retrieve the Identity instance
pub fn read_global(cx: &App) -> &Self {
cx.global::<GlobalIdentity>().0.read(cx)
}
/// Set the Global Identity instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalIdentity(state));
}
pub(crate) fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let client_keys = ClientKeys::global(cx);
let mut subscriptions = smallvec![];
subscriptions.push(
cx.observe_in(&client_keys, window, |this, state, window, cx| {
let auto_login = AppSettings::get_auto_login(cx);
let has_client_keys = state.read(cx).has_keys();
// Skip auto login if the user hasn't enabled auto login
if has_client_keys && auto_login {
this.set_logging_in(true, cx);
this.load(window, cx);
} else {
this.set_public_key(None, window, cx);
}
}),
);
Self {
public_key: None,
need_backup: None,
has_dm_relays: None,
need_onboarding: false,
logging_in: false,
subscriptions,
}
}
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let task = cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ACCOUNT_D)
.limit(1);
if let Some(event) = nostr_client().database().query(filter).await?.first_owned() {
let secret = event.content;
let is_bunker = secret.starts_with("bunker://");
Ok((secret, is_bunker))
} else {
Err(anyhow!("Not found"))
}
});
cx.spawn_in(window, async move |this, cx| {
if let Ok((secret, is_bunker)) = task.await {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.login(&secret, is_bunker, window, cx);
})
.ok();
})
.ok();
} else {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_public_key(None, window, cx);
})
.ok();
})
.ok();
}
})
.detach();
}
pub fn unload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let client = nostr_client();
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ACCOUNT_D);
// Reset the nostr client
client.unset_signer().await;
client.unsubscribe_all().await;
// Delete account
client.database().delete(filter).await?;
Ok(())
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_public_key(None, window, cx);
})
.ok();
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
};
})
.detach();
}
pub(crate) fn login(
&mut self,
secret: &str,
is_bunker: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if is_bunker {
if let Ok(uri) = NostrConnectURI::parse(secret) {
self.login_with_bunker(uri, window, cx);
} else {
window.push_notification(Notification::error("Bunker URI is invalid"), cx);
self.set_public_key(None, window, cx);
}
} else if let Ok(enc) = EncryptedSecretKey::from_bech32(secret) {
self.login_with_keys(enc, window, cx);
} else {
window.push_notification(Notification::error("Secret Key is invalid"), cx);
self.set_public_key(None, window, cx);
}
}
pub(crate) fn login_with_bunker(
&mut self,
uri: NostrConnectURI,
window: &mut Window,
cx: &mut Context<Self>,
) {
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT / 10);
let client_keys = ClientKeys::get_global(cx).keys();
let Ok(mut signer) = NostrConnect::new(uri, client_keys, timeout, None) else {
window.push_notification(
Notification::error("Bunker URI is invalid").title("Nostr Connect"),
cx,
);
self.set_public_key(None, window, cx);
return;
};
// Automatically open auth url
signer.auth_url_handler(CoopAuthUrlHandler);
cx.spawn_in(window, async move |this, cx| {
// Call .bunker_uri() to verify the connection
match signer.bunker_uri().await {
Ok(_) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_signer(signer, window, cx);
})
.ok();
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
this.update(cx, |this, cx| {
this.set_public_key(None, window, cx);
})
.ok();
})
.ok();
}
};
})
.detach();
}
pub(crate) fn login_with_keys(
&mut self,
enc: EncryptedSecretKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
let pwd_input: Entity<InputState> = cx.new(|cx| InputState::new(window, cx).masked(true));
let weak_input = pwd_input.downgrade();
let error: Entity<Option<SharedString>> = cx.new(|_| None);
let weak_error = error.downgrade();
let entity = cx.weak_entity();
window.open_modal(cx, move |this, _window, cx| {
let entity = entity.clone();
let entity_clone = entity.clone();
let weak_input = weak_input.clone();
let weak_error = weak_error.clone();
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.on_cancel(move |_, window, cx| {
entity
.update(cx, |this, cx| {
this.set_public_key(None, window, cx);
})
.ok();
// true to close the modal
true
})
.on_ok(move |_, window, cx| {
let weak_error = weak_error.clone();
let password = weak_input
.read_with(cx, |state, _cx| state.value().to_owned())
.ok();
entity_clone
.update(cx, |this, cx| {
this.verify_keys(enc, password, weak_error, window, cx);
})
.ok();
// false to keep the modal open
false
})
.child(
div()
.w_full()
.flex()
.flex_col()
.gap_1()
.text_sm()
.child("Password to decrypt your key *")
.child(TextInput::new(&pwd_input).small())
.when_some(error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.italic()
.text_color(red())
.child(error.clone()),
)
}),
)
});
}
pub(crate) fn verify_keys(
&mut self,
enc: EncryptedSecretKey,
password: Option<SharedString>,
error: WeakEntity<Option<SharedString>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(password) = password else {
_ = error.update(cx, |this, cx| {
*this = Some("Password is required".into());
cx.notify();
});
return;
};
if password.is_empty() {
_ = error.update(cx, |this, cx| {
*this = Some("Password cannot be empty".into());
cx.notify();
});
return;
}
// Decrypt the password in the background to prevent blocking the main thread
let task: Task<Result<SecretKey, Error>> = cx.background_spawn(async move {
let secret = enc.decrypt(&password)?;
Ok(secret)
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(secret) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
// Update user's signer with decrypted secret key
this.set_signer(Keys::new(secret), window, cx);
// Close the current modal
window.close_modal(cx);
})
.ok();
})
.ok();
}
Err(e) => {
error
.update(cx, |this, cx| {
*this = Some(e.to_string().into());
cx.notify();
})
.ok();
}
}
})
.detach();
}
/// Sets a new signer for the client and updates user identity
pub fn set_signer<S>(&mut self, signer: S, window: &mut Window, cx: &mut Context<Self>)
where
S: NostrSigner + 'static,
{
let task: Task<Result<PublicKey, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let public_key = signer.get_public_key().await?;
// Update signer
client.set_signer(signer).await;
// Subscribe for user metadata
get_nip65_relays(public_key).await?;
Ok(public_key)
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(public_key) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_public_key(Some(public_key), window, cx);
})
.ok();
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
};
})
.detach();
}
/// Creates a new identity with the given metadata
pub fn new_identity(
&mut self,
metadata: Metadata,
window: &mut Window,
cx: &mut Context<Self>,
) {
let keys = Keys::generate();
let async_keys = keys.clone();
let task: Task<Result<PublicKey, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let public_key = async_keys.public_key();
// Update signer
client.set_signer(async_keys).await;
// Set metadata
client.set_metadata(&metadata).await?;
// Create relay list
let relay_list = EventBuilder::new(Kind::RelayList, "").tags(
NIP65_RELAYS
.into_iter()
.filter_map(|url| RelayUrl::parse(url).ok())
.map(|url| Tag::relay_metadata(url, None)),
);
// Create messaging relay list
let dm_relay = EventBuilder::new(Kind::InboxRelays, "").tags(
NIP17_RELAYS
.into_iter()
.filter_map(|url| RelayUrl::parse(url).ok())
.map(Tag::relay),
);
// Set user's NIP65 relays
client.send_event_builder(relay_list).await?;
// Set user's NIP17 relays
client.send_event_builder(dm_relay).await?;
// Get user's NIP65 relays
get_nip65_relays(public_key).await?;
Ok(public_key)
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(public_key) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_public_key(Some(public_key), window, cx);
this.set_need_backup(Some(keys), cx);
this.set_need_onboarding(cx);
})
.ok();
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
};
})
.detach();
}
/// Clear the user's need backup status
pub fn clear_need_backup(&mut self, password: String, cx: &mut Context<Self>) {
if let Some(keys) = self.need_backup.as_ref() {
// Encrypt the keys then writing them to keychain
self.write_keys(keys, password, cx);
// Clear the needed backup keys
self.need_backup = None;
cx.notify();
}
}
/// Set the user's need backup status
pub(crate) fn set_need_backup(&mut self, keys: Option<Keys>, cx: &mut Context<Self>) {
self.need_backup = keys;
cx.notify();
}
/// Set the user's need onboarding status
pub(crate) fn set_need_onboarding(&mut self, cx: &mut Context<Self>) {
self.need_onboarding = true;
cx.notify();
}
/// Returns true if the user needs backup their keys
pub fn need_backup(&self) -> Option<&Keys> {
self.need_backup.as_ref()
}
/// Returns true if the user needs onboarding
pub fn need_onboarding(&self) -> bool {
self.need_onboarding
}
/// Writes the bunker uri to the database
pub fn write_bunker(&self, uri: &NostrConnectURI, cx: &mut Context<Self>) {
let mut value = uri.to_string();
let Some(public_key) = uri.remote_signer_public_key().cloned() else {
log::error!("Remote Signer's public key not found");
return;
};
// Remove the secret param if it exists
if let Some(secret) = uri.secret() {
value = value.replace(secret, "");
}
cx.background_spawn(async move {
let client = nostr_client();
let keys = Keys::generate();
let builder = EventBuilder::new(Kind::ApplicationSpecificData, value).tags(vec![
Tag::identifier(ACCOUNT_D),
Tag::public_key(public_key),
]);
if let Ok(event) = builder.sign(&keys).await {
if let Err(e) = client.database().save_event(&event).await {
log::error!("Failed to save event: {e}");
};
}
})
.detach();
}
/// Writes the keys to the database
pub fn write_keys(&self, keys: &Keys, password: String, cx: &mut Context<Self>) {
let keys = keys.to_owned();
let public_key = keys.public_key();
cx.background_spawn(async move {
if let Ok(enc_key) =
EncryptedSecretKey::new(keys.secret_key(), &password, 8, KeySecurity::Unknown)
{
let client = nostr_client();
let content = enc_key.to_bech32().unwrap();
let builder = EventBuilder::new(Kind::ApplicationSpecificData, content).tags(vec![
Tag::identifier(ACCOUNT_D),
Tag::public_key(public_key),
]);
if let Ok(event) = builder.sign(&Keys::generate()).await {
if let Err(e) = client.database().save_event(&event).await {
log::error!("Failed to save event: {e}");
};
}
}
})
.detach();
}
/// Sets the public key of the identity
pub(crate) fn set_public_key(
&mut self,
public_key: Option<PublicKey>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.public_key = public_key;
cx.notify();
}
/// Returns the current identity's public key
pub fn public_key(&self) -> Option<PublicKey> {
self.public_key
}
/// Returns true if a signer is currently set
pub fn has_signer(&self) -> bool {
self.public_key.is_some()
}
/// Returns true if the identity has DM Relays
pub fn has_dm_relays(&self) -> Option<bool> {
self.has_dm_relays
}
/// Returns true if the identity is currently logging in
pub fn logging_in(&self) -> bool {
self.logging_in
}
/// Sets the DM Relays status of the identity
pub fn set_has_dm_relays(&mut self, cx: &mut Context<Self>) {
self.has_dm_relays = Some(true);
cx.notify();
}
/// Sets the logging in status of the identity
pub(crate) fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.logging_in = status;
cx.notify();
}
}
async fn get_nip65_relays(public_key: PublicKey) -> Result<(), Error> {
let client = nostr_client();
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let sub_id = SubscriptionId::new("nip65-relays");
let filter = Filter::new()
.kind(Kind::RelayList)
.author(public_key)
.limit(1);
client.subscribe_with_id(sub_id, filter, Some(opts)).await?;
Ok(())
}

View File

@@ -1,17 +1,19 @@
[package]
name = "global"
name = "key_store"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
nostr-connect.workspace = true
nostr-sdk.workspace = true
dirs.workspace = true
smol.workspace = true
futures.workspace = true
log.workspace = true
anyhow.workspace = true
common = { path = "../common" }
whoami = "1.5.2"
rustls = "0.23.23"
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
futures.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -0,0 +1,211 @@
use std::any::Any;
use std::collections::HashMap;
use std::fmt::Display;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use anyhow::Result;
use common::config_dir;
use futures::FutureExt as _;
use gpui::AsyncApp;
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Credential {
public_key: PublicKey,
secret: String,
}
impl Credential {
pub fn new(user: String, secret: Vec<u8>) -> Self {
Self {
public_key: PublicKey::parse(&user).unwrap(),
secret: String::from_utf8(secret).unwrap(),
}
}
pub fn public_key(&self) -> PublicKey {
self.public_key
}
pub fn secret(&self) -> &str {
&self.secret
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KeyItem {
User,
Bunker,
}
impl Display for KeyItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::User => write!(f, "coop-user"),
Self::Bunker => write!(f, "coop-bunker"),
}
}
}
impl From<KeyItem> for String {
fn from(item: KeyItem) -> Self {
item.to_string()
}
}
pub trait KeyBackend: Any + Send + Sync {
fn name(&self) -> &str;
/// Reads the credentials from the provider.
#[allow(clippy::type_complexity)]
fn read_credentials<'a>(
&'a self,
url: &'a str,
cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>>;
/// Writes the credentials to the provider.
fn write_credentials<'a>(
&'a self,
url: &'a str,
username: &'a str,
password: &'a [u8],
cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>>;
/// Deletes the credentials from the provider.
fn delete_credentials<'a>(
&'a self,
url: &'a str,
cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>>;
}
/// A credentials provider that stores credentials in the system keychain.
pub struct KeyringProvider;
impl KeyBackend for KeyringProvider {
fn name(&self) -> &str {
"keyring"
}
fn read_credentials<'a>(
&'a self,
url: &'a str,
cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
async move { cx.update(|cx| cx.read_credentials(url)).await }.boxed_local()
}
fn write_credentials<'a>(
&'a self,
url: &'a str,
username: &'a str,
password: &'a [u8],
cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
async move {
cx.update(move |cx| cx.write_credentials(url, username, password))
.await
}
.boxed_local()
}
fn delete_credentials<'a>(
&'a self,
url: &'a str,
cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
async move { cx.update(move |cx| cx.delete_credentials(url)).await }.boxed_local()
}
}
/// A credentials provider that stores credentials in a local file.
pub struct FileProvider {
path: PathBuf,
}
impl FileProvider {
pub fn new() -> Self {
let path = config_dir().join(".keys");
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
Self { path }
}
pub fn load_credentials(&self) -> Result<HashMap<String, (String, Vec<u8>)>> {
let json = std::fs::read(&self.path)?;
let credentials: HashMap<String, (String, Vec<u8>)> = serde_json::from_slice(&json)?;
Ok(credentials)
}
pub fn save_credentials(&self, credentials: &HashMap<String, (String, Vec<u8>)>) -> Result<()> {
let json = serde_json::to_string(credentials)?;
std::fs::write(&self.path, json)?;
Ok(())
}
}
impl Default for FileProvider {
fn default() -> Self {
Self::new()
}
}
impl KeyBackend for FileProvider {
fn name(&self) -> &str {
"file"
}
fn read_credentials<'a>(
&'a self,
url: &'a str,
_cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
async move {
Ok(self
.load_credentials()
.unwrap_or_default()
.get(url)
.cloned())
}
.boxed_local()
}
fn write_credentials<'a>(
&'a self,
url: &'a str,
username: &'a str,
password: &'a [u8],
_cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
async move {
let mut credentials = self.load_credentials().unwrap_or_default();
credentials.insert(url.to_string(), (username.to_string(), password.to_vec()));
self.save_credentials(&credentials)
}
.boxed_local()
}
fn delete_credentials<'a>(
&'a self,
url: &'a str,
_cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
async move {
let mut credentials = self.load_credentials()?;
credentials.remove(url);
self.save_credentials(&credentials)
}
.boxed_local()
}
}

View File

@@ -0,0 +1,94 @@
use std::sync::{Arc, LazyLock};
pub use backend::*;
use gpui::{App, AppContext, Context, Entity, Global, Task};
use smallvec::{smallvec, SmallVec};
mod backend;
static DISABLE_KEYRING: LazyLock<bool> =
LazyLock::new(|| std::env::var("DISABLE_KEYRING").is_ok_and(|value| !value.is_empty()));
pub fn init(cx: &mut App) {
KeyStore::set_global(cx.new(KeyStore::new), cx);
}
struct GlobalKeyStore(Entity<KeyStore>);
impl Global for GlobalKeyStore {}
pub struct KeyStore {
/// Key Store for storing credentials
pub backend: Arc<dyn KeyBackend>,
/// Whether the keystore has been initialized
pub initialized: bool,
/// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 1]>,
}
impl KeyStore {
/// Retrieve the global keys state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalKeyStore>().0.clone()
}
/// Set the global keys instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalKeyStore(state));
}
/// Create a new keys instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
// Use the file system for keystore in development or when the user specifies it
let use_file_keystore = cfg!(debug_assertions) || *DISABLE_KEYRING;
// Construct the key backend
let backend: Arc<dyn KeyBackend> = if use_file_keystore {
Arc::new(FileProvider::default())
} else {
Arc::new(KeyringProvider)
};
// Only used for testing keyring availability on the user's system
let read_credential = cx.read_credentials("Coop");
let mut tasks = smallvec![];
tasks.push(
// Verify the keyring availability
cx.spawn(async move |this, cx| {
let result = read_credential.await;
this.update(cx, |this, cx| {
if let Err(e) = result {
log::error!("Keyring error: {e}");
// For Linux:
// The user has not installed secret service on their system
// Fall back to the file provider
this.backend = Arc::new(FileProvider::default());
}
this.initialized = true;
cx.notify();
})
.ok();
}),
);
Self {
backend,
initialized: false,
_tasks: tasks,
}
}
/// Returns the key backend.
pub fn backend(&self) -> Arc<dyn KeyBackend> {
Arc::clone(&self.backend)
}
/// Returns true if the keystore is a file key backend.
pub fn is_using_file_keystore(&self) -> bool {
self.backend.name() == "file"
}
}

View File

@@ -1,14 +1,17 @@
[package]
name = "client_keys"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
global = { path = "../global" }
nostr-sdk.workspace = true
gpui.workspace = true
anyhow.workspace = true
log.workspace = true
smallvec.workspace = true
[package]
name = "person"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
state = { path = "../state" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
smol.workspace = true
flume.workspace = true
log.workspace = true

318
crates/person/src/lib.rs Normal file
View File

@@ -0,0 +1,318 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::time::Duration;
use anyhow::{anyhow, Error};
use common::{EventUtils, BOOTSTRAP_RELAYS};
use gpui::{App, AppContext, Context, Entity, Global, Task};
use nostr_sdk::prelude::*;
pub use person::*;
use smallvec::{smallvec, SmallVec};
use state::{Announcement, NostrRegistry, TIMEOUT};
mod person;
pub fn init(cx: &mut App) {
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
}
struct GlobalPersonRegistry(Entity<PersonRegistry>);
impl Global for GlobalPersonRegistry {}
#[derive(Debug, Clone)]
enum Dispatch {
Person(Box<Person>),
Announcement(Box<Event>),
}
/// Person Registry
#[derive(Debug)]
pub struct PersonRegistry {
/// Collection of all persons (user profiles)
persons: HashMap<PublicKey, Entity<Person>>,
/// Set of public keys that have been seen
seen: Rc<RefCell<HashSet<PublicKey>>>,
/// Sender for requesting metadata
sender: flume::Sender<PublicKey>,
/// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 4]>,
}
impl PersonRegistry {
/// Retrieve the global person registry
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalPersonRegistry>().0.clone()
}
/// Set the global person registry instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalPersonRegistry(state));
}
/// Create a new person registry instance
fn new(cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Dispatch>(100);
let (mta_tx, mta_rx) = flume::bounded::<PublicKey>(100);
let mut tasks = smallvec![];
tasks.push(
// Handle nostr notifications
cx.background_spawn({
let client = client.clone();
async move {
Self::handle_notifications(&client, &tx).await;
}
}),
);
tasks.push(
// Handle metadata requests
cx.background_spawn({
let client = client.clone();
async move {
Self::handle_requests(&client, &mta_rx).await;
}
}),
);
tasks.push(
// Update GPUI state
cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await {
this.update(cx, |this, cx| {
match event {
Dispatch::Person(person) => {
this.insert(*person, cx);
}
Dispatch::Announcement(event) => {
this.set_announcement(&event, cx);
}
};
})
.ok();
}
}),
);
tasks.push(
// Load all user profiles from the database
cx.spawn(async move |this, cx| {
let result = cx
.background_executor()
.await_on_background(async move { Self::load_persons(&client).await })
.await;
match result {
Ok(persons) => {
this.update(cx, |this, cx| {
this.bulk_inserts(persons, cx);
})
.ok();
}
Err(e) => {
log::error!("Failed to load all persons from the database: {e}");
}
};
}),
);
Self {
persons: HashMap::new(),
seen: Rc::new(RefCell::new(HashSet::new())),
sender: mta_tx,
_tasks: tasks,
}
}
/// Handle nostr notifications
async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) {
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
while let Ok(notification) = notifications.recv().await {
let RelayPoolNotification::Message { message, .. } = notification else {
// Skip if the notification is not a message
continue;
};
if let RelayMessage::Event { event, .. } = message {
if !processed_events.insert(event.id) {
// Skip if the event has already been processed
continue;
}
match event.kind {
Kind::Metadata => {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata);
let val = Box::new(person);
// Send
tx.send_async(Dispatch::Person(val)).await.ok();
}
Kind::Custom(10044) => {
let val = Box::new(event.into_owned());
// Send
tx.send_async(Dispatch::Announcement(val)).await.ok();
}
Kind::ContactList => {
let public_keys = event.extract_public_keys();
// Get metadata for all public keys
Self::get_metadata(client, public_keys).await.ok();
}
_ => {}
}
}
}
}
/// Handle request for metadata
async fn handle_requests(client: &Client, rx: &flume::Receiver<PublicKey>) {
let mut batch: HashSet<PublicKey> = HashSet::new();
loop {
match flume::Selector::new()
.recv(rx, |result| result.ok())
.wait_timeout(Duration::from_secs(2))
{
Ok(Some(public_key)) => {
log::info!("Received public key: {}", public_key);
batch.insert(public_key);
// Process the batch if it's full
if batch.len() >= 20 {
Self::get_metadata(client, std::mem::take(&mut batch))
.await
.ok();
}
}
_ => {
Self::get_metadata(client, std::mem::take(&mut batch))
.await
.ok();
}
}
}
}
/// Get metadata for all public keys in a event
async fn get_metadata<I>(client: &Client, public_keys: I) -> Result<(), Error>
where
I: IntoIterator<Item = PublicKey>,
{
let authors: Vec<PublicKey> = public_keys.into_iter().collect();
let limit = authors.len();
if authors.is_empty() {
return Err(anyhow!("You need at least one public key"));
}
// Construct the subscription option
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
// Construct the filter for metadata
let filter = Filter::new()
.kind(Kind::Metadata)
.authors(authors)
.limit(limit);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await?;
Ok(())
}
/// Load all user profiles from the database
async fn load_persons(client: &Client) -> Result<Vec<Person>, Error> {
let filter = Filter::new().kind(Kind::Metadata).limit(200);
let events = client.database().query(filter).await?;
let mut persons = vec![];
for event in events.into_iter() {
let metadata = Metadata::from_json(event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata);
persons.push(person);
}
Ok(persons)
}
/// Set profile encryption keys announcement
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
if let Some(person) = self.persons.get(&event.pubkey) {
let announcement = Announcement::from(event);
person.update(cx, |person, cx| {
person.set_announcement(announcement);
cx.notify();
});
}
}
/// Insert batch of persons
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
for person in persons.into_iter() {
self.persons.insert(person.public_key(), cx.new(|_| person));
}
cx.notify();
}
/// Insert or update a person
pub fn insert(&mut self, person: Person, cx: &mut App) {
let public_key = person.public_key();
match self.persons.get(&public_key) {
Some(this) => {
this.update(cx, |this, cx| {
*this = person;
cx.notify();
});
}
None => {
self.persons.insert(public_key, cx.new(|_| person));
}
}
}
/// Get single person by public key
pub fn get(&self, public_key: &PublicKey, cx: &App) -> Person {
if let Some(person) = self.persons.get(public_key) {
return person.read(cx).clone();
}
let public_key = *public_key;
let mut seen = self.seen.borrow_mut();
if seen.insert(public_key) {
let sender = self.sender.clone();
// Spawn background task to request metadata
cx.background_spawn(async move {
if let Err(e) = sender.send_async(public_key).await {
log::warn!("Failed to send public key for metadata request: {}", e);
}
})
.detach();
}
// Return a temporary profile with default metadata
Person::new(public_key, Metadata::default())
}
}

122
crates/person/src/person.rs Normal file
View File

@@ -0,0 +1,122 @@
use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
use gpui::SharedString;
use nostr_sdk::prelude::*;
use state::Announcement;
/// Person
#[derive(Debug, Clone)]
pub struct Person {
/// Public Key
public_key: PublicKey,
/// Metadata (profile)
metadata: Metadata,
/// Dekey (NIP-4e) announcement
announcement: Option<Announcement>,
}
impl PartialEq for Person {
fn eq(&self, other: &Self) -> bool {
self.public_key == other.public_key
}
}
impl Eq for Person {}
impl PartialOrd for Person {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Person {
fn cmp(&self, other: &Self) -> Ordering {
self.name().cmp(&other.name())
}
}
impl Hash for Person {
fn hash<H: Hasher>(&self, state: &mut H) {
self.public_key.hash(state)
}
}
impl From<PublicKey> for Person {
fn from(public_key: PublicKey) -> Self {
Self::new(public_key, Metadata::default())
}
}
impl Person {
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
Self {
public_key,
metadata,
announcement: None,
}
}
/// Get profile public key
pub fn public_key(&self) -> PublicKey {
self.public_key
}
/// Get profile metadata
pub fn metadata(&self) -> Metadata {
self.metadata.clone()
}
/// Get profile encryption keys announcement
pub fn announcement(&self) -> Option<Announcement> {
self.announcement.clone()
}
/// Set profile encryption keys announcement
pub fn set_announcement(&mut self, announcement: Announcement) {
self.announcement = Some(announcement);
log::info!("Updated announcement for: {}", self.public_key());
}
/// Get profile avatar
pub fn avatar(&self) -> SharedString {
self.metadata()
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| picture.into())
.unwrap_or_else(|| "brand/avatar.png".into())
}
/// Get profile name
pub fn name(&self) -> SharedString {
if let Some(display_name) = self.metadata().display_name.as_ref() {
if !display_name.is_empty() {
return SharedString::from(display_name);
}
}
if let Some(name) = self.metadata().name.as_ref() {
if !name.is_empty() {
return SharedString::from(name);
}
}
SharedString::from(shorten_pubkey(self.public_key(), 4))
}
}
/// Shorten a [`PublicKey`] to a string with the first and last `len` characters
///
/// Ex. `00000000:00000002`
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
let Ok(pubkey) = public_key.to_bech32();
format!(
"{}:{}",
&pubkey[0..(len + 1)],
&pubkey[pubkey.len() - len..]
)
}

View File

@@ -1,445 +0,0 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use anyhow::Error;
use common::event::EventUtils;
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use global::nostr_client;
use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use room::RoomKind;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use crate::room::Room;
pub mod message;
pub mod room;
i18n::init!();
pub fn init(cx: &mut App) {
Registry::set_global(cx.new(Registry::new), cx);
}
struct GlobalRegistry(Entity<Registry>);
impl Global for GlobalRegistry {}
#[derive(Debug)]
pub enum RegistrySignal {
Open(WeakEntity<Room>),
Close(u64),
NewRequest(RoomKind),
}
/// Main registry for managing chat rooms and user profiles
pub struct Registry {
/// Collection of all chat rooms
pub rooms: Vec<Entity<Room>>,
/// Collection of all persons (user profiles)
pub persons: BTreeMap<PublicKey, Entity<Profile>>,
/// Indicates if rooms are currently being loaded
///
/// Always equal to `true` when the app starts
pub loading: bool,
/// Subscriptions for observing changes
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
}
impl EventEmitter<RegistrySignal> for Registry {}
impl Registry {
/// Retrieve the Global Registry state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalRegistry>().0.clone()
}
/// Retrieve the Registry instance
pub fn read_global(cx: &App) -> &Self {
cx.global::<GlobalRegistry>().0.read(cx)
}
/// Set the global Registry instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalRegistry(state));
}
/// Create a new Registry instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
// Load all user profiles from the database when the Registry is created
subscriptions.push(cx.observe_new::<Self>(|this, _window, cx| {
let task = this.load_local_person(cx);
this.set_persons_from_task(task, cx);
}));
// When any Room is created, load members metadata
subscriptions.push(cx.observe_new::<Room>(|this, _window, cx| {
let state = Self::global(cx);
let task = this.load_metadata(cx);
state.update(cx, |this, cx| {
this.set_persons_from_task(task, cx);
});
}));
Self {
rooms: vec![],
persons: BTreeMap::new(),
loading: true,
subscriptions,
}
}
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms = vec![];
self.loading = true;
cx.notify();
}
pub(crate) fn set_persons_from_task(
&mut self,
task: Task<Result<Vec<Profile>, Error>>,
cx: &mut Context<Self>,
) {
cx.spawn(async move |this, cx| {
if let Ok(profiles) = task.await {
this.update(cx, |this, cx| {
for profile in profiles {
this.persons
.insert(profile.public_key(), cx.new(|_| profile));
}
cx.notify();
})
.ok();
}
})
.detach();
}
pub(crate) fn load_local_person(&self, cx: &App) -> Task<Result<Vec<Profile>, Error>> {
cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::Metadata).limit(100);
let events = nostr_client().database().query(filter).await?;
let mut profiles = vec![];
for event in events.into_iter() {
let metadata = Metadata::from_json(event.content).unwrap_or_default();
let profile = Profile::new(event.pubkey, metadata);
profiles.push(profile);
}
Ok(profiles)
})
}
pub fn get_person(&self, public_key: &PublicKey, cx: &App) -> Profile {
self.persons
.get(public_key)
.map(|e| e.read(cx))
.cloned()
.unwrap_or(Profile::new(public_key.to_owned(), Metadata::default()))
}
pub fn get_group_person(&self, public_keys: &[PublicKey], cx: &App) -> Vec<Profile> {
let mut profiles = vec![];
for public_key in public_keys.iter() {
let profile = self.get_person(public_key, cx);
profiles.push(profile);
}
profiles
}
pub fn insert_or_update_person(&mut self, event: Event, cx: &mut App) {
let public_key = event.pubkey;
let Ok(metadata) = Metadata::from_json(event.content) else {
// Invalid metadata, no need to process further.
return;
};
if let Some(person) = self.persons.get(&public_key) {
person.update(cx, |this, cx| {
*this = Profile::new(public_key, metadata);
cx.notify();
});
} else {
self.persons
.insert(public_key, cx.new(|_| Profile::new(public_key, metadata)));
}
}
/// Get a room by its ID.
pub fn room(&self, id: &u64, cx: &App) -> Option<Entity<Room>> {
self.rooms
.iter()
.find(|model| model.read(cx).id == *id)
.cloned()
}
/// Get all ongoing rooms.
pub fn ongoing_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).kind == RoomKind::Ongoing)
.cloned()
.collect()
}
/// Get all request rooms.
pub fn request_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).kind != RoomKind::Ongoing)
.cloned()
.collect()
}
/// Add a new room to the start of list.
pub fn add_room(&mut self, room: Entity<Room>, cx: &mut Context<Self>) {
self.rooms.insert(0, room);
cx.notify();
}
/// Close a room.
pub fn close_room(&mut self, id: u64, cx: &mut Context<Self>) {
if self.rooms.iter().any(|r| r.read(cx).id == id) {
cx.emit(RegistrySignal::Close(id));
}
}
/// Sort rooms by their created at.
pub fn sort(&mut self, cx: &mut Context<Self>) {
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
cx.notify();
}
/// Search rooms by their name.
pub fn search(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
let matcher = SkimMatcherV2::default();
self.rooms
.iter()
.filter(|room| {
matcher
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
.is_some()
})
.cloned()
.collect()
}
/// Search rooms by public keys.
pub fn search_by_public_key(&self, public_key: PublicKey, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).members.contains(&public_key))
.cloned()
.collect()
}
/// Set the loading status of the registry.
pub fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
}
/// Load all rooms from the database.
pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
log::info!("Starting to load chat rooms...");
// Get the contact bypass setting
let contact_bypass = AppSettings::get_contact_bypass(cx);
let task: Task<Result<BTreeSet<Room>, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
// Get messages sent by the user
let send = Filter::new()
.kind(Kind::PrivateDirectMessage)
.author(public_key);
// Get messages received by the user
let recv = Filter::new()
.kind(Kind::PrivateDirectMessage)
.pubkey(public_key);
let send_events = client.database().query(send).await?;
let recv_events = client.database().query(recv).await?;
let events = send_events.merge(recv_events);
let mut rooms: BTreeSet<Room> = BTreeSet::new();
// Process each event and group by room hash
for event in events
.into_iter()
.sorted_by_key(|event| Reverse(event.created_at))
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
{
if rooms.iter().any(|room| room.id == event.uniq_id()) {
continue;
}
// Get all public keys from the event
let public_keys = event.all_pubkeys();
// Bypass screening flag
let mut bypass = false;
// If user enabled bypass screening for contacts
// Check if room's members are in contact with current user
if contact_bypass {
let contacts = client.database().contacts_public_keys(public_key).await?;
bypass = public_keys.iter().any(|k| contacts.contains(k));
}
// Check if the current user has sent at least one message to this room
let filter = Filter::new()
.kind(Kind::PrivateDirectMessage)
.author(public_key)
.pubkeys(public_keys);
// If current user has sent a message at least once, mark as ongoing
let is_ongoing = client.database().count(filter).await.unwrap_or(1) >= 1;
// Create a new room
let room = Room::new(&event).rearrange_by(public_key);
if is_ongoing || bypass {
rooms.insert(room.kind(RoomKind::Ongoing));
} else {
rooms.insert(room);
}
}
Ok(rooms)
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(rooms) => {
this.update(cx, |this, cx| {
this.extend_rooms(rooms, cx);
this.sort(cx);
})
.ok();
}
Err(e) => {
log::error!("Failed to load rooms: {e}")
}
};
})
.detach();
}
pub(crate) fn extend_rooms(&mut self, rooms: BTreeSet<Room>, cx: &mut Context<Self>) {
let mut room_map: HashMap<u64, usize> = HashMap::with_capacity(self.rooms.len());
for (index, room) in self.rooms.iter().enumerate() {
room_map.insert(room.read(cx).id, index);
}
for new_room in rooms.into_iter() {
// Check if we already have a room with this ID
if let Some(&index) = room_map.get(&new_room.id) {
self.rooms[index].update(cx, |this, cx| {
*this = new_room;
cx.notify();
});
} else {
let new_index = self.rooms.len();
room_map.insert(new_room.id, new_index);
self.rooms.push(cx.new(|_| new_room));
}
}
}
/// Push a new Room to the global registry
pub fn push_room(&mut self, room: Entity<Room>, cx: &mut Context<Self>) {
let other_id = room.read(cx).id;
let find_room = self.rooms.iter().find(|this| this.read(cx).id == other_id);
let weak_room = if let Some(room) = find_room {
room.downgrade()
} else {
let weak_room = room.downgrade();
// Add this room to the registry
self.add_room(room, cx);
weak_room
};
cx.emit(RegistrySignal::Open(weak_room));
}
/// Refresh messages for a room in the global registry
pub fn refresh_rooms(&mut self, ids: Vec<u64>, cx: &mut Context<Self>) {
for room in self.rooms.iter() {
if ids.contains(&room.read(cx).id) {
room.update(cx, |this, cx| {
this.emit_refresh(cx);
});
}
}
}
/// Parse a Nostr event into a Coop Message and push it to the belonging room
///
/// If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages.
pub fn event_to_message(
&mut self,
identity: PublicKey,
event: Event,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = event.uniq_id();
let author = event.pubkey;
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
// Update room
room.update(cx, |this, cx| {
this.created_at(event.created_at, cx);
// Set this room is ongoing if the new message is from current user
if author == identity {
this.set_ongoing(cx);
}
// Emit the new message to the room
cx.defer_in(window, |this, window, cx| {
this.emit_message(event, window, cx);
});
});
// Re-sort the rooms registry by their created at
self.sort(cx);
} else {
let room = Room::new(&event)
.kind(RoomKind::default())
.rearrange_by(identity);
// Push the new room to the front of the list
self.add_room(cx.new(|_| room), cx);
// Notify the UI about the new room
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(RegistrySignal::NewRequest(RoomKind::default()));
});
}
}
}

View File

@@ -1,179 +0,0 @@
use std::hash::Hash;
use std::iter::IntoIterator;
use chrono::{Local, TimeZone};
use gpui::SharedString;
use nostr_sdk::prelude::*;
use crate::room::SendError;
/// Represents a message in the chat system.
///
/// Contains information about the message content, author, creation time,
/// mentions, replies, and any errors that occurred during sending.
#[derive(Debug, Clone)]
pub struct Message {
/// Unique identifier of the message (EventId from nostr_sdk)
pub id: EventId,
/// Author's public key
pub author: PublicKey,
/// The content/text of the message
pub content: SharedString,
/// When the message was created
pub created_at: Timestamp,
/// List of mentioned public keys in the message
pub mentions: Vec<PublicKey>,
/// List of EventIds this message is replying to
pub replies_to: Option<Vec<EventId>>,
/// Any errors that occurred while sending this message
pub errors: Option<Vec<SendError>>,
}
impl Eq for Message {}
impl PartialEq for Message {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Ord for Message {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.created_at.cmp(&other.created_at)
}
}
impl PartialOrd for Message {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Hash for Message {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
/// Builder pattern implementation for constructing Message objects.
#[derive(Debug)]
pub struct MessageBuilder {
id: EventId,
author: PublicKey,
content: Option<SharedString>,
created_at: Option<Timestamp>,
mentions: Vec<PublicKey>,
replies_to: Option<Vec<EventId>>,
errors: Option<Vec<SendError>>,
}
impl MessageBuilder {
/// Creates a new MessageBuilder with default values
pub fn new(id: EventId, author: PublicKey) -> Self {
Self {
id,
author,
content: None,
created_at: None,
mentions: vec![],
replies_to: None,
errors: None,
}
}
/// Sets the message content
pub fn content(mut self, content: impl Into<SharedString>) -> Self {
self.content = Some(content.into());
self
}
/// Sets the creation timestamp
pub fn created_at(mut self, created_at: Timestamp) -> Self {
self.created_at = Some(created_at);
self
}
/// Adds a single mention to the message
pub fn mention(mut self, mention: PublicKey) -> Self {
self.mentions.push(mention);
self
}
/// Adds multiple mentions to the message
pub fn mentions<I>(mut self, mentions: I) -> Self
where
I: IntoIterator<Item = PublicKey>,
{
self.mentions.extend(mentions);
self
}
/// Sets a single message this is replying to
pub fn reply_to(mut self, reply_to: EventId) -> Self {
self.replies_to = Some(vec![reply_to]);
self
}
/// Sets multiple messages this is replying to
pub fn replies_to<I>(mut self, replies_to: I) -> Self
where
I: IntoIterator<Item = EventId>,
{
let replies: Vec<EventId> = replies_to.into_iter().collect();
if !replies.is_empty() {
self.replies_to = Some(replies);
}
self
}
/// Adds errors that occurred during sending
pub fn errors<I>(mut self, errors: I) -> Self
where
I: IntoIterator<Item = SendError>,
{
self.errors = Some(errors.into_iter().collect());
self
}
/// Builds the message
pub fn build(self) -> Result<Message, String> {
Ok(Message {
id: self.id,
author: self.author,
content: self.content.ok_or("Content is required")?,
created_at: self.created_at.unwrap_or_else(Timestamp::now),
mentions: self.mentions,
replies_to: self.replies_to,
errors: self.errors,
})
}
}
impl Message {
/// Creates a new MessageBuilder
pub fn builder(id: EventId, author: PublicKey) -> MessageBuilder {
MessageBuilder::new(id, author)
}
/// Returns a human-readable string representing how long ago the message was created
pub fn ago(&self) -> SharedString {
let input_time = match Local.timestamp_opt(self.created_at.as_u64() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return "Invalid timestamp".into(),
};
let now = Local::now();
let input_date = input_time.date_naive();
let now_date = now.date_naive();
let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
let time_format = input_time.format("%H:%M %p");
match input_date {
date if date == now_date => format!("Today at {time_format}"),
date if date == yesterday_date => format!("Yesterday at {time_format}"),
_ => format!("{}, {time_format}", input_time.format("%d/%m/%y")),
}
.into()
}
}

View File

@@ -1,670 +0,0 @@
use std::cmp::Ordering;
use anyhow::{anyhow, Error};
use chrono::{Local, TimeZone};
use common::display::DisplayProfile;
use common::event::EventUtils;
use global::nostr_client;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use smallvec::SmallVec;
use crate::message::Message;
use crate::Registry;
pub(crate) const NOW: &str = "now";
pub(crate) const SECONDS_IN_MINUTE: i64 = 60;
pub(crate) const MINUTES_IN_HOUR: i64 = 60;
pub(crate) const HOURS_IN_DAY: i64 = 24;
pub(crate) const DAYS_IN_MONTH: i64 = 30;
#[derive(Debug, Clone)]
pub enum RoomSignal {
NewMessage(Message),
Refresh,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SendError {
pub profile: Profile,
pub message: SharedString,
}
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum RoomKind {
Ongoing,
#[default]
Request,
}
#[derive(Debug)]
pub struct Room {
pub id: u64,
pub created_at: Timestamp,
/// Subject of the room
pub subject: Option<SharedString>,
/// Picture of the room
pub picture: Option<SharedString>,
/// All members of the room
pub members: SmallVec<[PublicKey; 2]>,
/// Kind
pub kind: RoomKind,
}
impl Ord for Room {
fn cmp(&self, other: &Self) -> Ordering {
self.created_at.cmp(&other.created_at)
}
}
impl PartialOrd for Room {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for Room {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for Room {}
impl EventEmitter<RoomSignal> for Room {}
impl Room {
pub fn new(event: &Event) -> Self {
let id = event.uniq_id();
let created_at = event.created_at;
let public_keys = event.all_pubkeys();
// Convert pubkeys into members
let members = public_keys.into_iter().unique().sorted().collect();
// Get the subject from the event's tags
let subject = if let Some(tag) = event.tags.find(TagKind::Subject) {
tag.content().map(|s| s.to_owned().into())
} else {
None
};
// Get the picture from the event's tags
let picture = if let Some(tag) = event.tags.find(TagKind::custom("picture")) {
tag.content().map(|s| s.to_owned().into())
} else {
None
};
Self {
id,
created_at,
subject,
picture,
members,
kind: RoomKind::default(),
}
}
/// Sets the kind of the room and returns the modified room
///
/// This is a builder-style method that allows chaining room modifications.
///
/// # Arguments
///
/// * `kind` - The RoomKind to set for this room
///
/// # Returns
///
/// The modified Room instance with the new kind
pub fn kind(mut self, kind: RoomKind) -> Self {
self.kind = kind;
self
}
/// Sets the rearrange_by field of the room and returns the modified room
///
/// This is a builder-style method that allows chaining room modifications.
///
/// # Arguments
///
/// * `rearrange_by` - The PublicKey to set for rearranging the member list
///
/// # Returns
///
/// The modified Room instance with the new member list after rearrangement
pub fn rearrange_by(mut self, rearrange_by: PublicKey) -> Self {
let (not_match, matches): (Vec<PublicKey>, Vec<PublicKey>) = self
.members
.into_iter()
.partition(|key| key != &rearrange_by);
self.members = not_match.into();
self.members.extend(matches);
self
}
/// Set the room kind to ongoing
///
/// # Arguments
///
/// * `cx` - The context to notify about the update
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
if self.kind != RoomKind::Ongoing {
self.kind = RoomKind::Ongoing;
cx.notify();
}
}
/// Checks if the room is a group chat
///
/// # Returns
///
/// true if the room has more than 2 members, false otherwise
pub fn is_group(&self) -> bool {
self.members.len() > 2
}
/// Updates the creation timestamp of the room
///
/// # Arguments
///
/// * `created_at` - The new Timestamp to set
/// * `cx` - The context to notify about the update
pub fn created_at(&mut self, created_at: impl Into<Timestamp>, cx: &mut Context<Self>) {
self.created_at = created_at.into();
cx.notify();
}
/// Updates the subject of the room
///
/// # Arguments
///
/// * `subject` - The new subject to set
/// * `cx` - The context to notify about the update
pub fn subject(&mut self, subject: impl Into<SharedString>, cx: &mut Context<Self>) {
self.subject = Some(subject.into());
cx.notify();
}
/// Updates the picture of the room
///
/// # Arguments
///
/// * `picture` - The new subject to set
/// * `cx` - The context to notify about the update
pub fn picture(&mut self, picture: impl Into<SharedString>, cx: &mut Context<Self>) {
self.picture = Some(picture.into());
cx.notify();
}
/// Returns a human-readable string representing how long ago the room was created
///
/// The string will be formatted differently based on the time elapsed:
/// - Less than a minute: "now"
/// - Less than an hour: "Xm" (minutes)
/// - Less than a day: "Xh" (hours)
/// - Less than a month: "Xd" (days)
/// - More than a month: "MMM DD" (month abbreviation and day)
///
/// # Returns
///
/// A SharedString containing the formatted time representation
pub fn ago(&self) -> SharedString {
let input_time = match Local.timestamp_opt(self.created_at.as_u64() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return "1m".into(),
};
let now = Local::now();
let duration = now.signed_duration_since(input_time);
match duration {
d if d.num_seconds() < SECONDS_IN_MINUTE => NOW.into(),
d if d.num_minutes() < MINUTES_IN_HOUR => format!("{}m", d.num_minutes()),
d if d.num_hours() < HOURS_IN_DAY => format!("{}h", d.num_hours()),
d if d.num_days() < DAYS_IN_MONTH => format!("{}d", d.num_days()),
_ => input_time.format("%b %d").to_string(),
}
.into()
}
/// Gets the display name for the room
///
/// If the room has a subject set, that will be used as the display name.
/// Otherwise, it will generate a name based on the room members.
///
/// # Arguments
///
/// * `cx` - The application context
///
/// # Returns
///
/// A SharedString containing the display name
pub fn display_name(&self, cx: &App) -> SharedString {
if let Some(subject) = self.subject.clone() {
subject
} else {
self.merge_name(cx)
}
}
/// Gets the display image for the room
///
/// The image is determined by:
/// - The room's picture if set
/// - The first member's avatar for 1:1 chats
/// - A default group image for group chats
///
/// # Arguments
///
/// * `proxy` - Whether to use the proxy for the avatar URL
/// * `cx` - The application context
///
/// # Returns
///
/// A SharedString containing the image path or URL
pub fn display_image(&self, proxy: bool, cx: &App) -> SharedString {
if let Some(picture) = self.picture.as_ref() {
picture.clone()
} else if !self.is_group() {
self.first_member(cx).avatar_url(proxy)
} else {
"brand/group.png".into()
}
}
/// Get the first member of the room.
///
/// First member is always different from the current user.
pub(crate) fn first_member(&self, cx: &App) -> Profile {
let registry = Registry::read_global(cx);
registry.get_person(&self.members[0], cx)
}
/// Merge the names of the first two members of the room.
pub(crate) fn merge_name(&self, cx: &App) -> SharedString {
let registry = Registry::read_global(cx);
if self.is_group() {
let profiles = self
.members
.iter()
.map(|pk| registry.get_person(pk, cx))
.collect::<Vec<_>>();
let mut name = profiles
.iter()
.take(2)
.map(|p| p.display_name())
.collect::<Vec<_>>()
.join(", ");
if profiles.len() > 2 {
name = format!("{}, +{}", name, profiles.len() - 2);
}
name.into()
} else {
self.first_member(cx).display_name()
}
}
/// Loads all profiles for this room members from the database
///
/// # Arguments
///
/// * `cx` - The App context
///
/// # Returns
///
/// A Task that resolves to Result<Vec<Profile>, Error> containing all profiles for this room
pub fn load_metadata(&self, cx: &mut Context<Self>) -> Task<Result<Vec<Profile>, Error>> {
let public_keys = self.members.clone();
cx.background_spawn(async move {
let database = nostr_client().database();
let mut profiles = vec![];
for public_key in public_keys.into_iter() {
let metadata = database.metadata(public_key).await?.unwrap_or_default();
profiles.push(Profile::new(public_key, metadata));
}
Ok(profiles)
})
}
/// Loads all messages for this room from the database
///
/// # Arguments
///
/// * `cx` - The App context
///
/// # Returns
///
/// A Task that resolves to Result<Vec<RoomMessage>, Error> containing all messages for this room
pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<Message>, Error>> {
let pubkeys = self.members.clone();
let filter = Filter::new()
.kind(Kind::PrivateDirectMessage)
.authors(self.members.clone())
.pubkeys(self.members.clone());
cx.background_spawn(async move {
let mut messages = vec![];
let parser = NostrParser::new();
let database = nostr_client().database();
// Get all events from database
let events = database
.query(filter)
.await?
.into_iter()
.sorted_by_key(|ev| ev.created_at)
.filter(|ev| ev.compare_pubkeys(&pubkeys))
.collect::<Vec<_>>();
for event in events.into_iter() {
let content = event.content.clone();
let tokens = parser.parse(&content);
let mut replies_to = vec![];
for tag in event.tags.filter(TagKind::e()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
for tag in event.tags.filter(TagKind::q()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
let mentions = tokens
.filter_map(|token| match token {
Token::Nostr(nip21) => match nip21 {
Nip21::Pubkey(pubkey) => Some(pubkey),
Nip21::Profile(profile) => Some(profile.public_key),
_ => None,
},
_ => None,
})
.collect::<Vec<_>>();
if let Ok(message) = Message::builder(event.id, event.pubkey)
.content(content)
.created_at(event.created_at)
.replies_to(replies_to)
.mentions(mentions)
.build()
{
messages.push(message);
}
}
Ok(messages)
})
}
/// Emits a message event to the GPUI
///
/// # Arguments
///
/// * `event` - The Nostr event to emit
/// * `window` - The Window to emit the event to
/// * `cx` - The context for the room
///
/// # Effects
///
/// Processes the event and emits an Incoming to the UI when complete
pub fn emit_message(&self, event: Event, _window: &mut Window, cx: &mut Context<Self>) {
// Extract all mentions from content
let mentions = extract_mentions(&event.content);
// Extract reply_to if present
let mut replies_to = vec![];
for tag in event.tags.filter(TagKind::e()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
for tag in event.tags.filter(TagKind::q()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
if let Ok(message) = Message::builder(event.id, event.pubkey)
.content(event.content)
.created_at(event.created_at)
.replies_to(replies_to)
.mentions(mentions)
.build()
{
cx.emit(RoomSignal::NewMessage(message));
}
}
/// Emits a signal to refresh the current room's messages.
pub fn emit_refresh(&mut self, cx: &mut Context<Self>) {
cx.emit(RoomSignal::Refresh);
log::info!("refresh room: {}", self.id);
}
/// Creates a temporary message for optimistic updates
///
/// This constructs an unsigned message with the current user as the author,
/// extracts any mentions from the content, and packages it as a Message struct.
/// The message will have a generated ID but hasn't been published to relays.
///
/// # Arguments
///
/// * `content` - The message content text
/// * `cx` - The application context containing user profile information
///
/// # Returns
///
/// Returns `Some(Message)` containing the temporary message if the current user's profile is available,
/// or `None` if no account is found.
pub fn create_temp_message(
&self,
public_key: PublicKey,
content: &str,
replies: Option<&Vec<Message>>,
) -> Option<Message> {
let builder = EventBuilder::private_msg_rumor(public_key, content);
// Add event reference if it's present (replying to another event)
let mut refs = vec![];
if let Some(replies) = replies {
if replies.len() == 1 {
refs.push(Tag::event(replies[0].id))
} else {
for message in replies.iter() {
refs.push(Tag::custom(TagKind::q(), vec![message.id]))
}
}
}
let mut event = if !refs.is_empty() {
builder.tags(refs).build(public_key)
} else {
builder.build(public_key)
};
// Create a unsigned event to convert to Coop Message
event.ensure_id();
// Extract all mentions from content
let mentions = extract_mentions(&event.content);
// Extract reply_to if present
let mut replies_to = vec![];
for tag in event.tags.filter(TagKind::e()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
for tag in event.tags.filter(TagKind::q()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
Message::builder(event.id.unwrap(), public_key)
.content(event.content)
.created_at(event.created_at)
.replies_to(replies_to)
.mentions(mentions)
.build()
.ok()
}
/// Sends a message to all members in the background task
///
/// # Arguments
///
/// * `content` - The content of the message to send
/// * `cx` - The App context
///
/// # Returns
///
/// A Task that resolves to Result<Vec<String>, Error> where the
/// strings contain error messages for any failed sends
pub fn send_in_background(
&self,
content: &str,
replies: Option<&Vec<Message>>,
backup: bool,
cx: &App,
) -> Task<Result<Vec<SendError>, Error>> {
let content = content.to_owned();
let replies = replies.cloned();
let subject = self.subject.clone();
let picture = self.picture.clone();
let public_keys = self.members.clone();
cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let mut reports = vec![];
let mut tags: Vec<Tag> = public_keys
.iter()
.filter_map(|pubkey| {
if pubkey != &public_key {
Some(Tag::public_key(*pubkey))
} else {
None
}
})
.collect();
// Add event reference if it's present (replying to another event)
if let Some(replies) = replies {
if replies.len() == 1 {
tags.push(Tag::event(replies[0].id))
} else {
for message in replies.iter() {
tags.push(Tag::custom(TagKind::q(), vec![message.id]))
}
}
}
// Add subject tag if it's present
if let Some(subject) = subject {
tags.push(Tag::from_standardized(TagStandard::Subject(
subject.to_string(),
)));
}
// Add picture tag if it's present
if let Some(picture) = picture {
tags.push(Tag::custom(TagKind::custom("picture"), vec![picture]));
}
let Some((current_user, receivers)) = public_keys.split_last() else {
return Err(anyhow!("Something is wrong. Cannot get receivers list."));
};
for receiver in receivers.iter() {
if let Err(e) = client
.send_private_msg(*receiver, &content, tags.clone())
.await
{
let metadata = client
.database()
.metadata(*receiver)
.await?
.unwrap_or_default();
let profile = Profile::new(*receiver, metadata);
let report = SendError {
profile,
message: e.to_string().into(),
};
reports.push(report);
}
}
// Only send a backup message to current user if there are no issues when sending to others
if backup && reports.is_empty() {
if let Err(e) = client
.send_private_msg(*current_user, &content, tags.clone())
.await
{
let metadata = client
.database()
.metadata(*current_user)
.await?
.unwrap_or_default();
let profile = Profile::new(*current_user, metadata);
let report = SendError {
profile,
message: e.to_string().into(),
};
reports.push(report);
}
}
Ok(reports)
})
}
}
pub(crate) fn extract_mentions(content: &str) -> Vec<PublicKey> {
let parser = NostrParser::new();
let tokens = parser.parse(content);
tokens
.filter_map(|token| match token {
Token::Nostr(nip21) => match nip21 {
Nip21::Pubkey(pubkey) => Some(pubkey),
Nip21::Profile(profile) => Some(profile.public_key),
_ => None,
},
_ => None,
})
.collect::<Vec<_>>()
}

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