50 Commits

Author SHA1 Message Date
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
5bef1a2c6c chore: fix flatpak 2025-08-10 14:58:59 +07:00
cd26244538 chore: bump version 2025-08-10 12:50:52 +07:00
reya
ca622d1262 chore: improve logout behavior (#118)
* resubscribe on logout

* .

* .
2025-08-10 10:43:28 +07:00
reya
5011becacb chore: use a newer flatpak runtime (#117)
* use newer flatpak runtime

* .
2025-08-10 07:40:13 +07:00
reya
17f92d767e chore: improve ui consistency (#115)
* .

* .
2025-08-09 14:58:01 +07:00
be660cb14b chore: update deps 2025-08-08 13:14:11 +07:00
reya
8fca202c05 chore: refactor subscription (#113)
* fix duplicate set signer request

* refactor

* .
2025-08-07 20:53:21 +07:00
7b20131e3b chore: bump version 2025-08-06 10:11:04 +07:00
reya
9127696517 chore: fix missing message during initial fetch (#110)
* remove fetched flag

* .

* improve
2025-08-06 09:15:00 +07:00
reya
af74a4ed23 . (#109) 2025-08-06 07:10:00 +07:00
reya
bd2b72a57a chore: fix duplicate messages (#108)
* prevent duplicate message on load

* refactor
2025-08-05 21:15:10 +07:00
89 changed files with 7682 additions and 5277 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

View File

@@ -14,7 +14,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
rustup: [stable, nightly]
rustup: [stable]
runs-on: ${{ matrix.os }}

1390
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,12 @@ members = ["crates/*"]
default-members = ["crates/coop"]
[workspace.package]
version = "0.2.0"
version = "0.2.8"
edition = "2021"
publish = false
[workspace.metadata.i18n]
available-locales = ["en", "zh-CN", "zh-TW", "ru", "vi", "ja", "es", "pt", "ko"]
available-locales = ["en"]
default-locale = "en"
load-path = "locales"
@@ -42,6 +42,7 @@ 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"] }
@@ -49,6 +50,7 @@ serde_json = "1.0"
smallvec = "1.14.0"
smol = "2"
tracing = "0.1.40"
webbrowser = "1.0.4"
[profile.release]
strip = true

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

@@ -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

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

@@ -108,12 +108,9 @@ impl AutoUpdater {
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();
this.update_in(cx, |this, window, cx| {
this.set_status(AutoUpdateStatus::checked(update), cx);
this.install_update(window, cx);
})
.ok();
} else {

View File

@@ -1,4 +1,7 @@
use global::{constants::KEYRING_URL, first_run};
use std::sync::atomic::Ordering;
use global::constants::KEYRING_URL;
use global::css;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
@@ -24,7 +27,7 @@ impl ClientKeys {
}
/// Retrieve the Client Keys instance
pub fn get_global(cx: &App) -> &Self {
pub fn read_global(cx: &App) -> &Self {
cx.global::<GlobalClientKeys>().0.read(cx)
}
@@ -49,11 +52,21 @@ impl ClientKeys {
}
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Prevent macOS from asking for password every time
// Only for debug builds
if cfg!(debug_assertions) && cfg!(target_os = "macos") {
log::warn!("Running debug build on macOS");
log::warn!("Skipping keychain access, generating new client keys");
self.new_keys(cx);
return;
}
let css = css();
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
// Update the client keys with the stored secret key from the keychain
this.update(cx, |this, cx| {
let Ok(secret_key) = SecretKey::from_slice(&secret) else {
this.set_keys(None, false, true, cx);
@@ -63,8 +76,8 @@ impl ClientKeys {
this.set_keys(Some(keys), false, true, cx);
})
.ok();
} else if *first_run() {
// Generate a new keys and update
} else if css.is_first_run.load(Ordering::Acquire) {
// If this is the first run, generate new keys and use them for the client keys
this.update(cx, |this, cx| {
this.new_keys(cx);
})
@@ -102,6 +115,7 @@ impl ClientKeys {
}
self.keys = keys;
// Notify GPUI to reload UI
if notify {
cx.notify();
@@ -118,8 +132,7 @@ impl ClientKeys {
pub fn keys(&self) -> Keys {
self.keys
.as_ref()
.cloned()
.clone()
.expect("Keys should always be initialized")
}

View File

@@ -19,6 +19,6 @@ smol.workspace = true
futures.workspace = true
reqwest.workspace = true
log.workspace = true
webbrowser.workspace = true
webbrowser = "1.0.4"
qrcode-generator = "5.0.0"
qrcode = "0.14.1"

View File

@@ -1,20 +1,27 @@
use std::sync::Arc;
use anyhow::{anyhow, Error};
use chrono::{Local, TimeZone};
use global::constants::IMAGE_RESIZE_SERVICE;
use gpui::{Image, ImageFormat, SharedString};
use gpui::{Image, ImageFormat};
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";
pub trait DisplayProfile {
fn avatar_url(&self, proxy: bool) -> SharedString;
fn display_name(&self) -> SharedString;
pub trait ReadableProfile {
fn avatar_url(&self, proxy: bool) -> String;
fn display_name(&self) -> String;
}
impl DisplayProfile for Profile {
fn avatar_url(&self, proxy: bool) -> SharedString {
impl ReadableProfile for Profile {
fn avatar_url(&self, proxy: bool) -> String {
self.metadata()
.picture
.as_ref()
@@ -24,7 +31,6 @@ impl DisplayProfile for Profile {
format!(
"{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1"
)
.into()
} else {
picture.into()
}
@@ -32,7 +38,7 @@ impl DisplayProfile for Profile {
.unwrap_or_else(|| "brand/avatar.png".into())
}
fn display_name(&self) -> SharedString {
fn display_name(&self) -> String {
if let Some(display_name) = self.metadata().display_name.as_ref() {
if !display_name.is_empty() {
return display_name.into();
@@ -49,54 +55,86 @@ impl DisplayProfile for Profile {
}
}
pub trait ReadableTimestamp {
fn to_human_time(&self) -> String;
fn to_ago(&self) -> String;
}
impl ReadableTimestamp for Timestamp {
fn to_human_time(&self) -> String {
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return "9999".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")),
}
}
fn to_ago(&self) -> String {
let input_time = match Local.timestamp_opt(self.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(),
}
}
}
pub trait TextUtils {
fn to_public_key(&self) -> Result<PublicKey, Error>;
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 +142,4 @@ pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> SharedString {
&pubkey[0..(len + 1)],
&pubkey[pubkey.len() - len..]
)
.into()
}

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.0"
version = "0.2.8"
out-dir = "../../dist"
before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"]
@@ -30,7 +30,6 @@ icons = [
assets = { path = "../assets" }
ui = { path = "../ui" }
title_bar = { path = "../title_bar" }
identity = { path = "../identity" }
theme = { path = "../theme" }
common = { path = "../common" }
global = { path = "../global" }
@@ -38,6 +37,7 @@ registry = { path = "../registry" }
settings = { path = "../settings" }
client_keys = { path = "../client_keys" }
auto_update = { path = "../auto_update" }
signer_proxy = { path = "../signer_proxy" }
rust-i18n.workspace = true
i18n.workspace = true
@@ -59,5 +59,8 @@ smallvec.workspace = true
smol.workspace = true
futures.workspace = true
oneshot.workspace = true
flume.workspace = true
webbrowser.workspace = true
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
indexset = "0.12.3"

View File

@@ -1,7 +1,7 @@
{
"id": "$APP_ID",
"runtime": "org.freedesktop.Platform",
"runtime-version": "23.08",
"runtime-version": "24.08",
"sdk": "org.freedesktop.Sdk",
"sdk-extensions": ["org.freedesktop.Sdk.Extension.rust-stable"],
"command": "coop",

View File

@@ -0,0 +1,35 @@
use std::sync::Mutex;
use gpui::{actions, App};
actions!(coop, [ReloadMetadata, DarkMode, Settings, Logout, Quit]);
actions!(sidebar, [Reload, RelayStatus]);
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 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

@@ -1,182 +1,39 @@
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::{
ALL_MESSAGES_ID, APP_ID, APP_NAME, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT,
METADATA_BATCH_TIMEOUT, NEW_MESSAGE_ID, SEARCH_RELAYS,
};
use global::{nostr_client, set_all_gift_wraps_fetched, NostrSignal};
use global::constants::{APP_ID, APP_NAME};
use global::{css, nostr_client};
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 actions;
pub(crate) mod chatspace;
pub(crate) mod views;
i18n::init!();
actions!(coop, [Quit]);
fn main() {
// Initialize logging
tracing_subscriber::fmt::init();
// Initialize the Nostr Client
let client = nostr_client();
// Initialize the Nostr client
let _client = nostr_client();
// Initialize the coop simple storage
let _css = css();
// 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::unbounded::<Event>();
let signal_tx_clone = signal_tx.clone();
let mta_tx_clone = mta_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(client, &signal_tx_clone, &mta_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 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) => {
batch.insert(public_key);
// Process immediately if batch limit reached
if batch.len() >= METADATA_BATCH_LIMIT {
sync_data_for_pubkeys(client, std::mem::take(&mut batch)).await;
}
}
BatchEvent::Timeout => {
if !batch.is_empty() {
sync_data_for_pubkeys(client, std::mem::take(&mut batch)).await;
}
}
BatchEvent::Closed => {
if !batch.is_empty() {
sync_data_for_pubkeys(client, 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(30));
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) => {
// Process the gift wrap event unwrapping
let cached = try_unwrap_event(&signal_tx, &mta_tx, &event, false).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();
// Mark all gift wraps as fetched
// For the next time Coop only needs to process new gift wraps
set_all_gift_wraps_fetched().await;
break;
}
}
}
// Event channel is no longer needed when all gift wrap events have been processed
event_rx.close();
})
.detach();
// Run application
app.run(move |cx| {
// Load embedded fonts in assets/fonts
@@ -219,6 +76,9 @@ fn main() {
// Open a window with default options
cx.open_window(opts, |window, cx| {
// Bring the app to the foreground
cx.activate(true);
// Automatically sync theme with system appearance
window
.observe_window_appearance(|window, cx| {
@@ -228,342 +88,27 @@ fn main() {
// 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 settings
settings::init(cx);
// Initialize client keys
client_keys::init(cx);
// Initialize identity
identity::init(window, cx);
// Initialize app registry
registry::init(cx);
// Initialize settings
settings::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| {
let all_messages = SubscriptionId::new(ALL_MESSAGES_ID);
while let Ok(signal) = signal_rx.recv().await {
cx.update(|window, cx| {
let registry = Registry::global(cx);
let identity = Identity::read_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);
}
});
}
// Load chat rooms without setting as finished
NostrSignal::Eose(subscription_id) => {
// Only load chat rooms if the subscription ID matches the all_messages_sub_id
if subscription_id == all_messages {
registry.update(cx, |this, cx| {
this.load_rooms(window, 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.public_key() {
registry.update(cx, |this, cx| {
this.event_to_message(public_key, event, window, cx);
});
}
}
NostrSignal::Notice(_msg) => {
// window.push_notification(msg, cx);
}
};
})
.ok();
}
})
.detach();
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(
client: &Client,
signal_tx: &Sender<NostrSignal>,
mta_tx: &Sender<PublicKey>,
event_tx: &Sender<Event>,
) -> Result<(), Error> {
let new_messages = SubscriptionId::new(NEW_MESSAGE_ID);
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let mut notifications = client.notifications();
let mut processed_events: BTreeSet<EventId> = BTreeSet::new();
let mut processed_dm_relays: BTreeSet<PublicKey> = BTreeSet::new();
while let Ok(notification) = notifications.recv().await {
let RelayPoolNotification::Message { message, .. } = notification else {
continue;
};
match message {
RelayMessage::Event {
event,
subscription_id,
} => {
if processed_events.contains(&event.id) {
continue;
}
// Skip events that have already been processed
processed_events.insert(event.id);
match event.kind {
Kind::GiftWrap => {
if *subscription_id == new_messages {
let event = event.as_ref();
_ = try_unwrap_event(signal_tx, mta_tx, event, false).await;
} else {
event_tx.send(event.into_owned()).await.ok();
}
}
Kind::Metadata => {
signal_tx
.send(NostrSignal::Metadata(event.into_owned()))
.await
.ok();
}
Kind::ContactList => {
if let Ok(true) = check_author(client, &event).await {
for public_key in event.tags.public_keys().copied() {
mta_tx.send(public_key).await.ok();
}
}
}
Kind::RelayList => {
if processed_dm_relays.contains(&event.pubkey) {
continue;
}
// Skip public keys that have already been processed
processed_dm_relays.insert(event.pubkey);
let filter = Filter::new()
.author(event.pubkey)
.kind(Kind::InboxRelays)
.limit(1);
if let Ok(output) = client.subscribe(filter, Some(opts)).await {
log::info!(
"Subscribed to get DM relays: {} - Relays: {:?}",
event.pubkey.to_bech32().unwrap(),
output.success
)
}
}
_ => {}
}
}
RelayMessage::EndOfStoredEvents(subscription_id) => {
signal_tx
.send(NostrSignal::Eose(subscription_id.into_owned()))
.await?;
}
_ => {}
}
}
Ok(())
}
async fn check_author(client: &Client, event: &Event) -> Result<bool, Error> {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
Ok(public_key == event.pubkey)
}
async fn sync_data_for_pubkeys(client: &Client, public_keys: BTreeSet<PublicKey>) {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList];
let filter = Filter::new()
.limit(public_keys.len() * kinds.len())
.authors(public_keys)
.kinds(kinds);
if let Err(e) = client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
{
log::error!("Failed to sync metadata: {e}");
}
}
/// 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(
signal_tx: &Sender<NostrSignal>,
mta_tx: &Sender<PublicKey>,
event: &Event,
incoming: bool,
) -> 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;
}
}
}
};
// Get all pubkeys from the event
let all_pubkeys = event.all_pubkeys();
// Send all pubkeys to the metadata batch to sync data
for public_key in all_pubkeys {
mta_tx.send(public_key).await.ok();
}
// Send a notify to GPUI if this is a new message
if incoming {
signal_tx.send(NostrSignal::GiftWrap(event)).await.ok();
}
is_cached
}

View File

@@ -0,0 +1,401 @@
use std::time::Duration;
use anyhow::Error;
use client_keys::ClientKeys;
use common::display::ReadableProfile;
use common::handle_auth::CoopAuthUrlHandler;
use global::constants::{ACCOUNT_IDENTIFIER, BUNKER_TIMEOUT};
use global::{css, nostr_client, SignalKind};
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, Task, WeakEntity, Window,
};
use i18n::{shared_t, t};
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
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::input::{InputState, TextInput};
use ui::popup_menu::PopupMenu;
use ui::{h_flex, v_flex, ContextModal, Disableable, Sizable, StyledExt};
use crate::chatspace::ChatSpace;
pub fn init(
secret: String,
profile: Profile,
window: &mut Window,
cx: &mut App,
) -> Entity<Account> {
Account::new(secret, profile, window, cx)
}
pub struct Account {
profile: Profile,
stored_secret: String,
is_bunker: bool,
is_extension: bool,
loading: bool,
// Panel
name: SharedString,
focus_handle: FocusHandle,
_tasks: SmallVec<[Task<()>; 1]>,
}
impl Account {
fn new(secret: String, profile: Profile, _window: &mut Window, cx: &mut App) -> Entity<Self> {
let is_bunker = secret.starts_with("bunker://");
let is_extension = secret.starts_with("extension");
cx.new(|cx| Self {
profile,
is_bunker,
is_extension,
stored_secret: secret,
loading: false,
name: "Account".into(),
focus_handle: cx.focus_handle(),
_tasks: smallvec![],
})
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_loading(true, cx);
if self.is_bunker {
if let Ok(uri) = NostrConnectURI::parse(&self.stored_secret) {
self.nostr_connect(uri, window, cx);
}
} else if self.is_extension {
self.set_proxy(window, cx);
} else if let Ok(enc) = EncryptedSecretKey::from_bech32(&self.stored_secret) {
self.keys(enc, window, cx);
} else {
window.push_notification("Cannot continue with current account", cx);
self.set_loading(false, cx);
}
}
fn nostr_connect(&mut self, uri: NostrConnectURI, window: &mut Window, cx: &mut Context<Self>) {
let client_keys = ClientKeys::global(cx);
let app_keys = client_keys.read(cx).keys();
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
let mut signer = NostrConnect::new(uri, app_keys, timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
self._tasks.push(
// Handle connection
cx.spawn_in(window, async move |_this, cx| {
let client = nostr_client();
match signer.bunker_uri().await {
Ok(_) => {
// Set the client's signer with the current nostr connect instance
client.set_signer(signer).await;
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
}
}),
);
}
fn set_proxy(&mut self, window: &mut Window, cx: &mut Context<Self>) {
ChatSpace::proxy_signer(window, cx);
}
fn 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_loading(false, 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(shared_t!("login.password_to_decrypt"))
.child(TextInput::new(&pwd_input).small())
.when_some(error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
)
});
}
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();
})
.ok();
return;
};
if password.is_empty() {
error
.update(cx, |this, cx| {
*this = Some("Password cannot be empty".into());
cx.notify();
})
.ok();
return;
}
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| {
window.close_all_modals(cx);
})
.ok();
let client = nostr_client();
let keys = Keys::new(secret);
// Set the client's signer with the current keys
client.set_signer(keys).await
}
Err(e) => {
error
.update(cx, |this, cx| {
*this = Some(e.to_string().into());
cx.notify();
})
.ok();
}
};
})
.detach();
}
fn logout(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self._tasks.push(
// Reset the nostr client in the background
cx.background_spawn(async move {
let client = nostr_client();
let css = css();
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ACCOUNT_IDENTIFIER);
// Delete account
client.database().delete(filter).await.ok();
// Unset the client's signer
client.unset_signer().await;
// Notify the channel about the signer being unset
css.signal.send(SignalKind::SignerUnset).await;
}),
);
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
}
}
impl Panel for Account {
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 Account {}
impl Focusable for Account {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Account {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.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(shared_t!("welcome.title")),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("welcome.subtitle")),
),
),
)
.child(
v_flex()
.gap_2()
.child(
div()
.id("account")
.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(shared_t!("onboarding.choose_account"))
.child(
h_flex()
.gap_1()
.child(
Avatar::new(self.profile.avatar_url(true))
.size(rems(1.5)),
)
.child(
div()
.pb_px()
.font_semibold()
.child(self.profile.display_name()),
),
),
)
}
})
.hover(|this| this.bg(cx.theme().element_hover))
.on_click(cx.listener(move |this, _e, window, cx| {
this.login(window, cx);
})),
)
.child(
Button::new("logout")
.label(t!("user.sign_out"))
.ghost()
.disabled(self.loading)
.on_click(cx.listener(move |this, _e, window, cx| {
this.logout(window, cx);
})),
),
)
}
}

View File

@@ -5,56 +5,14 @@ use dirs::document_dir;
use gpui::prelude::FluentBuilder;
use gpui::{
div, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement, Render,
SharedString, Styled, Window,
SharedString, Styled, Task, Window,
};
use i18n::{shared_t, t};
use identity::Identity;
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::button::{Button, 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
})
})
}),
)
}
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable};
pub struct BackupKeys {
password: Entity<InputState>,
@@ -92,6 +50,42 @@ impl BackupKeys {
}
}
pub fn password(&self, cx: &Context<Self>) -> String {
self.password.read(cx).value().to_string()
}
pub fn backup(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Task<()>> {
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 None;
};
let path = cx.prompt_for_new_path(&document_dir, Some("My Nostr Account"));
let nsec = self.secret_input.read(cx).value().to_string();
Some(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| {
if let Err(e) = fs::write(&path, nsec) {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), window, cx);
})
.ok();
}
})
.ok();
}
_ => {
log::error!("Failed to save backup keys");
}
};
}))
}
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);
@@ -140,48 +134,6 @@ impl BackupKeys {
})
.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 {

File diff suppressed because it is too large Load Diff

View File

@@ -28,8 +28,8 @@ impl Subject {
cx.new(|_| Self { input })
}
pub fn new_subject(&self, cx: &App) -> SharedString {
self.input.read(cx).value().clone()
pub fn new_subject(&self, cx: &App) -> String {
self.input.read(cx).value().to_string()
}
}

View File

@@ -2,7 +2,7 @@ use std::ops::Range;
use std::time::Duration;
use anyhow::{anyhow, Error};
use common::display::{DisplayProfile, TextUtils};
use common::display::{ReadableProfile, TextUtils};
use common::nip05::nip05_profile;
use global::constants::BOOTSTRAP_RELAYS;
use global::nostr_client;
@@ -22,7 +22,7 @@ use smallvec::{smallvec, SmallVec};
use smol::Timer;
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::notification::Notification;
use ui::{h_flex, v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
@@ -34,7 +34,7 @@ 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"));

View File

@@ -69,32 +69,29 @@ impl EditProfile {
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();
this.update_in(cx, |this: &mut EditProfile, window, 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();
}
@@ -112,6 +109,7 @@ impl EditProfile {
files: true,
directories: false,
multiple: false,
prompt: None,
});
// Show loading spinner
@@ -166,7 +164,7 @@ impl EditProfile {
.detach();
}
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Option<Event>, Error>> {
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Option<Profile>, 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();
@@ -191,7 +189,14 @@ impl EditProfile {
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?;
let event = client
.database()
.event_by_id(&output.val)
.await?
.map(|event| {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
Profile::new(event.pubkey, metadata)
});
Ok(event)
})

View File

@@ -1,38 +1,30 @@
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 global::constants::{ACCOUNT_IDENTIFIER, BUNKER_TIMEOUT};
use global::nostr_client;
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,
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, 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};
use ui::{v_flex, 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
input: Entity<InputState>,
error: Entity<Option<SharedString>>,
countdown: Entity<Option<u64>>,
logging_in: bool,
@@ -40,7 +32,7 @@ pub struct Login {
name: SharedString,
focus_handle: FocusHandle,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 3]>,
subscriptions: SmallVec<[Subscription; 1]>,
}
impl Login {
@@ -49,110 +41,29 @@ impl Login {
}
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 input = cx.new(|cx| InputState::new(window, cx).placeholder("nsec... or bunker://..."));
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| {
cx.subscribe_in(&input, window, |this, _e, 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 {
input,
error,
countdown,
subscriptions,
name: "Login".into(),
focus_handle: cx.focus_handle(),
logging_in: false,
countdown,
key_input,
relay_input,
connection_string,
qr_image,
error,
subscriptions,
}
}
@@ -160,17 +71,18 @@ impl Login {
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| {
self.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() {
match self.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),
@@ -314,7 +226,8 @@ impl Login {
}
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 value = self.input.read(cx).value().to_string();
let secret_key = if value.starts_with("nsec1") {
SecretKey::parse(&value).ok()
} else if value.starts_with("ncryptsec1") {
@@ -328,10 +241,15 @@ impl Login {
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);
});
// Encrypt and save user secret key to disk
self.write_keys_to_disk(&keys, password, cx);
// Set the client's signer with the current keys
cx.background_spawn(async move {
let client = nostr_client();
client.set_signer(keys).await;
})
.detach();
} else {
self.set_error(t!("login.key_invalid"), window, cx);
}
@@ -343,16 +261,18 @@ impl Login {
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();
let client_keys = ClientKeys::global(cx);
let app_keys = client_keys.read(cx).keys();
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
let mut signer = NostrConnect::new(uri, app_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() {
for i in (0..=BUNKER_TIMEOUT).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
@@ -371,26 +291,28 @@ impl Login {
// Handle connection
cx.spawn_in(window, async move |this, cx| {
let client = nostr_client();
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(uri) => {
this.update(cx, |this, cx| {
this.write_uri_to_disk(&uri, cx);
})
.ok();
// Set the client's signer with the current nostr connect instance
client.set_signer(signer).await;
}
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.set_error(error.to_string(), window, cx);
// Force reset the client keys
//
// This step is necessary to ensure that user can retry the connection
client_keys.update(cx, |this, cx| {
this.force_new_keys(cx);
});
this.set_error(error.to_string(), window, cx);
})
.ok();
})
@@ -401,55 +323,68 @@ impl Login {
.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);
fn write_uri_to_disk(&mut self, uri: &NostrConnectURI, cx: &mut Context<Self>) {
let Some(public_key) = uri.remote_signer_public_key().cloned() else {
log::error!("Remote Signer's public key not found");
return;
};
let client_keys = ClientKeys::get_global(cx).keys();
let uri = NostrConnectURI::client(client_keys.public_key(), vec![relay_url], "Coop");
let mut value = uri.to_string();
self.connection_string.update(cx, |this, cx| {
*this = uri;
cx.notify();
});
// Clear 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 tags = vec![Tag::identifier(ACCOUNT_IDENTIFIER)];
let kind = Kind::ApplicationSpecificData;
let builder = EventBuilder::new(kind, value)
.tags(tags)
.build(public_key)
.sign(&keys)
.await;
if let Ok(event) = builder {
if let Err(e) = client.database().save_event(&event).await {
log::error!("Failed to save event: {e}");
};
}
})
.detach();
}
pub fn write_keys_to_disk(&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 value = enc_key.to_bech32().unwrap();
let keys = Keys::generate();
let tags = vec![Tag::identifier(ACCOUNT_IDENTIFIER)];
let kind = Kind::ApplicationSpecificData;
let builder = EventBuilder::new(kind, value)
.tags(tags)
.build(public_key)
.sign(&keys)
.await;
if let Ok(event) = builder {
if let Err(e) = client.database().save_event(&event).await {
log::error!("Failed to save event: {e}");
};
}
}
})
.detach();
}
fn set_error(
@@ -471,7 +406,7 @@ impl Login {
});
// Re enable the input
self.key_input.update(cx, |this, cx| {
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
this.set_loading(false, cx);
this.set_disabled(false, cx);
@@ -533,162 +468,64 @@ impl Focusable for Login {
impl Render for Login {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
v_flex()
.relative()
.flex()
.size_full()
.items_center()
.justify_center()
.child(
div()
.h_full()
.flex_1()
.flex()
.items_center()
.justify_center()
v_flex()
.w_96()
.gap_10()
.child(
div()
.w_80()
.flex()
.flex_col()
.gap_8()
.text_center()
.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")),
),
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(shared_t!("login.title")),
)
.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),
)
}),
.text_color(cx.theme().text_muted)
.child(shared_t!("login.key_description")),
),
),
)
.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(
)
.child(
v_flex()
.gap_3()
.child(TextInput::new(&self.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()
.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")),
),
.text_color(cx.theme().text_muted)
.child(shared_t!("login.approve_message", i = i)),
)
.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(
})
.when_some(self.error.read(cx).clone(), |this, error| {
this.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);
},
)),
),
),
),
),
.text_xs()
.text_center()
.text_color(cx.theme().danger_foreground)
.child(error),
)
}),
),
)
}
}

View File

@@ -1,14 +1,14 @@
pub mod account;
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 setup_relay;
pub mod sidebar;
pub mod startup;
pub mod user_profile;
pub mod welcome;

View File

@@ -1,5 +1,6 @@
use anyhow::anyhow;
use common::nip96::nip96_upload;
use global::constants::{ACCOUNT_IDENTIFIER, NIP17_RELAYS, NIP65_RELAYS};
use global::nostr_client;
use gpui::{
div, relative, rems, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity,
@@ -7,19 +8,21 @@ use gpui::{
Render, SharedString, Styled, WeakEntity, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use i18n::{shared_t, t};
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::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::popup_menu::PopupMenu;
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
use crate::views::backup_keys::BackupKeys;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
NewAccount::new(window, cx)
}
@@ -27,12 +30,11 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
pub struct NewAccount {
name_input: Entity<InputState>,
avatar_input: Entity<InputState>,
is_uploading: bool,
is_submitting: bool,
temp_keys: Entity<Keys>,
uploading: bool,
submitting: bool,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
@@ -42,43 +44,157 @@ impl NewAccount {
}
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"));
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,
is_uploading: false,
is_submitting: false,
temp_keys,
uploading: false,
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>) {
fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.submitting(true, cx);
let identity = Identity::global(cx);
let keys = self.temp_keys.read(cx).clone();
let view = cx.new(|cx| BackupKeys::new(&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(shared_t!("new_account.backup_label"))
.child(view.clone())
.button_props(
ModalButtonProps::default().ok_text(t!("new_account.backup_download")),
)
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
let password = this.password(cx);
let current_view = current_view.clone();
if let Some(task) = this.backup(window, cx) {
cx.spawn_in(window, async move |_, cx| {
task.await;
cx.update(|window, cx| {
current_view
.update(cx, |this, cx| {
this.set_signer(password, window, cx);
})
.ok();
})
.ok()
})
.detach();
}
})
.ok();
// true to close the modal
false
})
})
}
fn set_signer(&mut self, password: String, window: &mut Window, cx: &mut Context<Self>) {
window.close_modal(cx);
let keys = self.temp_keys.read(cx).clone();
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);
});
// Encrypt and save user secret key to disk
self.write_keys_to_disk(&keys, password, cx);
// Set the client's signer with the current keys
cx.background_spawn(async move {
let client = nostr_client();
// Set the client's signer with the current keys
client.set_signer(keys).await;
// Set metadata
if let Err(e) = client.set_metadata(&metadata).await {
log::error!("Failed to set metadata: {e}");
}
// Set NIP-65 relays
let builder = EventBuilder::new(Kind::RelayList, "").tags(
NIP65_RELAYS.into_iter().filter_map(|url| {
if let Ok(url) = RelayUrl::parse(url) {
Some(Tag::relay_metadata(url, None))
} else {
None
}
}),
);
if let Err(e) = client.send_event_builder(builder).await {
log::error!("Failed to send NIP-65 relay list event: {e}");
}
// Set NIP-17 relays
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(
NIP17_RELAYS.into_iter().filter_map(|url| {
if let Ok(url) = RelayUrl::parse(url) {
Some(Tag::relay(url))
} else {
None
}
}),
);
if let Err(e) = client.send_event_builder(builder).await {
log::error!("Failed to send messaging relay list event: {e}");
};
})
.detach();
}
fn write_keys_to_disk(&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 value = enc_key.to_bech32().unwrap();
let keys = Keys::generate();
let tags = vec![Tag::identifier(ACCOUNT_IDENTIFIER)];
let kind = Kind::ApplicationSpecificData;
let builder = EventBuilder::new(kind, value)
.tags(tags)
.build(public_key)
.sign(&keys)
.await;
if let Ok(event) = builder {
if let Err(e) = client.database().save_event(&event).await {
log::error!("Failed to save event: {e}");
};
}
}
})
.detach();
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -92,6 +208,7 @@ impl NewAccount {
files: true,
directories: false,
multiple: false,
prompt: None,
});
let task = Tokio::spawn(cx, async move {
@@ -114,14 +231,11 @@ impl NewAccount {
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();
this.update_in(cx, |this, window, cx| {
this.uploading(false, cx);
this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
});
})
.ok();
}
@@ -149,12 +263,12 @@ impl NewAccount {
}
fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
self.submitting = status;
cx.notify();
}
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_uploading = status;
self.uploading = status;
cx.notify();
}
}
@@ -168,14 +282,6 @@ impl Panel for NewAccount {
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)
}
@@ -207,7 +313,7 @@ impl Render for NewAccount {
.text_center()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::new(t!("new_account.title"))),
.child(shared_t!("new_account.title")),
)
.child(
v_flex()
@@ -217,17 +323,13 @@ impl Render for NewAccount {
v_flex()
.gap_1()
.text_sm()
.child(SharedString::new(t!("new_account.name")))
.child(shared_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(div().text_sm().child(shared_t!("new_account.avatar")))
.child(
v_flex()
.p_1()
@@ -250,9 +352,9 @@ impl Render for NewAccount {
.label(t!("common.upload"))
.ghost()
.small()
.rounded(ButtonRounded::Full)
.disabled(self.is_submitting)
.loading(self.is_uploading)
.rounded()
.disabled(self.submitting || self.uploading)
.loading(self.uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
@@ -262,12 +364,12 @@ impl Render for NewAccount {
.child(divider(cx))
.child(
Button::new("submit")
.label(SharedString::new(t!("common.continue")))
.label(t!("common.continue"))
.primary()
.loading(self.is_submitting)
.disabled(self.is_submitting || self.is_uploading)
.loading(self.submitting)
.disabled(self.submitting || self.uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.submit(window, cx);
this.create(window, cx);
})),
),
)

View File

@@ -1,40 +1,73 @@
use anyhow::anyhow;
use common::display::DisplayProfile;
use global::constants::ACCOUNT_D;
use std::sync::Arc;
use std::time::Duration;
use client_keys::ClientKeys;
use common::display::TextUtils;
use global::constants::{ACCOUNT_IDENTIFIER, APP_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
use global::nostr_client;
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, ClipboardItem, Context, Entity,
EventEmitter, FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window,
};
use i18n::t;
use identity::Identity;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use i18n::{shared_t, t};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
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::notification::Notification;
use ui::popup_menu::PopupMenu;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt};
use ui::{divider, h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
use crate::chatspace;
use crate::chatspace::{self, ChatSpace};
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 {
nostr_connect_uri: Entity<NostrConnectURI>,
nostr_connect: Entity<Option<NostrConnect>>,
qr_code: Entity<Option<Arc<Image>>>,
connecting: bool,
// Panel
name: SharedString,
local_account: Entity<Option<Profile>>,
loading: bool,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
_subscriptions: SmallVec<[Subscription; 2]>,
_tasks: SmallVec<[Task<()>; 1]>,
}
impl Onboarding {
@@ -43,63 +76,182 @@ impl Onboarding {
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let local_account = cx.new(|_| None);
let nostr_connect = cx.new(|_| None);
let qr_code = cx.new(|_| None);
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 public_key = event
.tags
.public_keys()
.copied()
.collect_vec()
.first()
.cloned()
.unwrap();
let metadata = nostr_client()
.database()
.metadata(public_key)
.await?
.unwrap_or_default();
Ok(Profile::new(public_key, metadata))
} else {
Err(anyhow!("Not found"))
}
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
//
// Direct connection initiated by the client
let nostr_connect_uri = cx.new(|cx| {
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
let app_keys = ClientKeys::read_global(cx).keys();
NostrConnectURI::client(app_keys.public_key(), vec![relay], APP_NAME)
});
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();
});
})
.ok();
}
})
.detach();
let mut subscriptions = smallvec![];
// Clean up when the current view is released
subscriptions.push(cx.on_release_in(window, |this, window, cx| {
this.shutdown_nostr_connect(window, cx);
}));
// Set Nostr Connect after the view is initialized
cx.defer_in(window, |this, window, cx| {
this.set_connect(window, cx);
});
Self {
local_account,
nostr_connect,
nostr_connect_uri,
qr_code,
connecting: false,
name: "Onboarding".into(),
loading: false,
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
_tasks: smallvec![],
}
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
fn set_connecting(&mut self, cx: &mut Context<Self>) {
self.connecting = true;
cx.notify();
}
fn set_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let uri = self.nostr_connect_uri.read(cx).clone();
let app_keys = ClientKeys::read_global(cx).keys();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
self.qr_code.update(cx, |this, cx| {
*this = uri.to_string().to_qr();
cx.notify();
});
self.nostr_connect.update(cx, |this, cx| {
*this = NostrConnect::new(uri, app_keys, timeout, None).ok();
cx.notify();
});
self._tasks.push(
// Wait for Nostr Connect approval
cx.spawn_in(window, async move |this, cx| {
let client = nostr_client();
let connect = this.read_with(cx, |this, cx| this.nostr_connect.read(cx).clone());
if let Ok(Some(signer)) = connect {
match signer.bunker_uri().await {
Ok(uri) => {
this.update(cx, |this, cx| {
this.set_connecting(cx);
this.write_uri_to_disk(&uri, cx);
})
.ok();
// Set the client's signer with the current nostr connect instance
client.set_signer(signer).await;
}
Err(e) => {
this.update_in(cx, |_, window, cx| {
window.push_notification(
Notification::error(e.to_string()).title("Nostr Connect"),
cx,
);
})
.ok();
}
};
}
}),
)
}
fn set_proxy(&mut self, window: &mut Window, cx: &mut Context<Self>) {
ChatSpace::proxy_signer(window, cx);
}
fn write_uri_to_disk(&mut self, uri: &NostrConnectURI, cx: &mut Context<Self>) {
let Some(public_key) = uri.remote_signer_public_key().cloned() else {
log::error!("Remote Signer's public key not found");
return;
};
let mut value = uri.to_string();
// Clear 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 tags = vec![Tag::identifier(ACCOUNT_IDENTIFIER)];
let kind = Kind::ApplicationSpecificData;
let builder = EventBuilder::new(kind, value)
.tags(tags)
.build(public_key)
.sign(&keys)
.await;
if let Ok(event) = builder {
if let Err(e) = client.database().save_event(&event).await {
log::error!("Failed to save event: {e}");
};
}
})
.detach();
}
fn copy_uri(&mut self, window: &mut Window, cx: &mut Context<Self>) {
cx.write_to_clipboard(ClipboardItem::new_string(
self.nostr_connect_uri.read(cx).to_string(),
));
window.push_notification(t!("common.copied"), cx);
}
fn shutdown_nostr_connect(&mut self, _window: &mut Window, cx: &mut App) {
if !self.connecting {
if let Some(signer) = self.nostr_connect.read(cx).clone() {
cx.background_spawn(async move {
log::info!("Shutting down Nostr Connect");
signer.shutdown().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_md()
.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);
}
})
}
}
impl Panel for Onboarding {
@@ -111,14 +263,6 @@ impl Panel for Onboarding {
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)
}
@@ -134,159 +278,167 @@ 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()
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(shared_t!("welcome.title")),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("welcome.subtitle")),
),
),
),
)
.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(shared_t!("onboarding.start_messaging"))
.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(shared_t!("onboarding.divider")),
)
.child(divider(cx)),
)
.child(
Button::new("key")
.label(t!("onboarding.key_login"))
.ghost_alt()
.on_click(cx.listener(move |_, _, window, cx| {
chatspace::login(window, cx);
})),
)
.child(
v_flex()
.gap_1()
.child(
Button::new("ext")
.label(t!("onboarding.ext_login"))
.ghost_alt()
.on_click(cx.listener(move |this, _, window, cx| {
this.set_proxy(window, cx);
})),
)
.child(
div()
.italic()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(shared_t!("onboarding.ext_login_note")),
),
),
)
}
})
),
)
.child(
div()
.relative()
.p_2()
.flex_1()
.h_full()
.rounded_2xl()
.child(
v_flex()
.size_full()
.justify_center()
.bg(cx.theme().surface_background)
.rounded_2xl()
.child(
v_flex()
.gap_5()
.items_center()
.justify_center()
.when_some(self.qr_code.read(cx).as_ref(), |this, qr| {
this.child(
div()
.id("")
.child(
img(qr.clone())
.size(px(256.))
.rounded_xl()
.shadow_lg()
.border_1()
.border_color(cx.theme().element_active),
)
.on_click(cx.listener(
move |this, _e, window, cx| {
this.copy_uri(window, cx)
},
)),
)
})
.child(
v_flex()
.justify_center()
.items_center()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.3))
.child(shared_t!("onboarding.nostr_connect")),
)
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(shared_t!("onboarding.scan_qr")),
)
.child(
h_flex()
.mt_2()
.gap_1()
.text_xs()
.justify_center()
.children(self.render_apps(cx)),
),
),
),
),
)
}
}

View File

@@ -1,12 +1,11 @@
use common::display::DisplayProfile;
use common::display::ReadableProfile;
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,
};
use i18n::t;
use identity::Identity;
use i18n::{shared_t, t};
use nostr_sdk::prelude::*;
use registry::Registry;
use settings::AppSettings;
use theme::ActiveTheme;
@@ -15,12 +14,12 @@ 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 ui::{h_flex, v_flex, ContextModal, IconName, Sizable, Size, StyledExt};
use crate::views::{edit_profile, messaging_relays};
use crate::views::{edit_profile, setup_relay};
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,44 +27,42 @@ 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)
});
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)
});
Self { media_input }
})
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())
.title(shared_t!("profile.title"))
.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);
let registry = Registry::global(cx);
cx.spawn_in(window, async move |_, cx| {
match set_metadata.await {
Ok(event) => {
if let Some(event) = event {
Ok(profile) => {
if let Some(profile) = profile {
cx.update(|_, cx| {
Registry::global(cx).update(cx, |this, cx| {
this.insert_or_update_person(event, cx);
registry.update(cx, |this, cx| {
this.insert_or_update_person(profile, cx);
});
})
.ok();
@@ -89,15 +86,14 @@ impl Preferences {
}
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 view = setup_relay::init(Kind::InboxRelays, 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())
.title(shared_t!("relays.modal"))
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
@@ -116,80 +112,69 @@ impl Preferences {
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 profile = Registry::read_global(cx).identity(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);
v_flex()
.child(
v_flex()
.py_2()
.pb_2()
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(SharedString::new(t!("preferences.account_header"))),
.child(shared_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(
h_flex()
.w_full()
.justify_between()
.child(
h_flex()
.id("user")
.gap_2()
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(2.4)))
.child(
div()
.flex_1()
.text_sm()
.child(
div()
.font_semibold()
.line_height(relative(1.3))
.child(profile.display_name()),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.line_height(relative(1.3))
.child(shared_t!("preferences.account_btn")),
),
)
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_edit_profile(window, cx);
})),
)
.child(
Button::new("relays")
.label("Messaging Relays")
.xsmall()
.ghost_alt()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_relays(window, cx);
})),
),
),
)
.child(
v_flex()
@@ -201,39 +186,48 @@ impl Render for Preferences {
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(SharedString::new(t!("preferences.media_server_header"))),
.child(shared_t!("preferences.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(shared_t!("preferences.media_description")),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("preferences.media_description"))),
Switch::new("auth")
.label(t!("preferences.auto_auth"))
.description(t!("preferences.auto_auth_description"))
.checked(auto_auth)
.on_click(move |_, _window, cx| {
AppSettings::update_auto_auth(!auto_auth, cx);
}),
),
)
.child(
@@ -247,7 +241,7 @@ impl Render for Preferences {
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(SharedString::new(t!("preferences.messages_header"))),
.child(shared_t!("preferences.messages_header")),
)
.child(
v_flex()
@@ -265,18 +259,18 @@ impl Render for Preferences {
Switch::new("bypass")
.label(t!("preferences.bypass_label"))
.description(t!("preferences.bypass_description"))
.checked(contact_bypass)
.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"))
Switch::new("backup")
.label(t!("preferences.backup_label"))
.description(t!("preferences.backup_description"))
.checked(backup_messages)
.checked(backup)
.on_click(move |_, _window, cx| {
AppSettings::update_backup_messages(!backup_messages, cx);
AppSettings::update_backup_messages(!backup, cx);
}),
),
),
@@ -292,27 +286,27 @@ impl Render for Preferences {
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(SharedString::new(t!("preferences.display_header"))),
.child(shared_t!("preferences.display_header")),
)
.child(
v_flex()
.gap_2()
.child(
Switch::new("hide_user_avatars")
Switch::new("hide_avatar")
.label(t!("preferences.hide_avatars_label"))
.description(t!("preferences.hide_avatar_description"))
.checked(hide_avatar)
.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")
Switch::new("proxy_avatar")
.label(t!("preferences.proxy_avatars_label"))
.description(t!("preferences.proxy_description"))
.checked(proxy_avatar)
.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,88 @@
use common::display::{shorten_pubkey, DisplayProfile};
use std::time::Duration;
use common::display::{shorten_pubkey, ReadableProfile, ReadableTimestamp};
use common::nip05::nip05_verify;
use global::constants::BOOTSTRAP_RELAYS;
use global::nostr_client;
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 smallvec::{smallvec, SmallVec};
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: Profile,
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 registry = Registry::read_global(cx);
let identity = registry.identity(cx).public_key();
let profile = registry.get_person(&public_key, cx);
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 mut tasks = smallvec![];
let check_trust_score: Task<(bool, usize, bool)> = cx.background_spawn(async move {
let contact_check: Task<(bool, Vec<Profile>)> = cx.background_spawn(async move {
let client = nostr_client();
let follow = Filter::new()
.kind(Kind::ContactList)
.author(identity)
.pubkey(public_key)
.limit(1);
// Check if user is in contact list
let contacts = client.database().contacts_public_keys(identity).await;
let followed = contacts.unwrap_or_default().contains(&public_key);
let contacts = Filter::new()
.kind(Kind::ContactList)
.pubkey(public_key)
.limit(1);
// Check mutual contacts
let contact_list = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![];
let relays = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
if let Ok(events) = client.database().query(contact_list).await {
for event in events.into_iter().filter(|ev| ev.pubkey != identity) {
if let Ok(metadata) = client.database().metadata(event.pubkey).await {
let profile = Profile::new(event.pubkey, metadata.unwrap_or_default());
mutual_contacts.push(profile);
}
}
}
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;
(is_follow, mutual_contacts, dm_relays)
(followed, mutual_contacts)
});
let verify_nip05 = if let Some(address) = self.address(cx) {
let activity_check = cx.background_spawn(async move {
let client = nostr_client();
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(event) = stream.next().await {
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,47 +90,70 @@ impl Screening {
None
};
cx.spawn_in(window, async move |this, cx| {
let (followed, mutual_contacts, dm_relays) = check_trust_score.await;
tasks.push(
// Run the contact check in the background
cx.spawn_in(window, async move |this, cx| {
let (followed, mutual_contacts) = contact_check.await;
this.update(cx, |this, cx| {
this.followed = followed;
this.mutual_contacts = mutual_contacts;
this.dm_relays = dm_relays;
cx.notify();
})
.ok();
this.update(cx, |this, cx| {
this.followed = followed;
this.mutual_contacts = mutual_contacts;
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();
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();
}
}
}
})
.detach();
}),
);
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 public_key = self.profile.public_key();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let client = nostr_client();
let builder = EventBuilder::report(
@@ -143,13 +176,55 @@ impl Screening {
})
.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(shared_t!("screening.mutual_label")).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_url(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 +234,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_url(proxy)).size(rems(4.)))
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(profile.display_name()),
.child(self.profile.display_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)
@@ -195,7 +268,7 @@ impl Render for Screening {
.label(t!("profile.njump"))
.secondary()
.small()
.rounded(ButtonRounded::Full)
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_njump(window, cx);
})),
@@ -205,7 +278,7 @@ impl Render for Screening {
.tooltip(t!("screening.report"))
.icon(IconName::Report)
.danger()
.rounded(ButtonRounded::Full)
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.report(window, cx);
})),
@@ -220,25 +293,70 @@ 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(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
shared_t!("screening.contact")
} else {
shared_t!("screening.not_contact")
}
}),
),
),
)
.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(shared_t!("screening.active_label"))
.child(
Button::new("active")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.tooltip(t!("screening.active_tooltip")),
),
)
.child(
div()
.w_full()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.map(|this| {
if let Some(date) = self.last_active {
this.child(shared_t!(
"screening.active_at",
d = date.to_human_time()
))
} else {
this.child(shared_t!("screening.no_active"))
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(Some(self.verified), cx))
.child(
v_flex()
.text_sm()
@@ -249,77 +367,83 @@ impl Render for Screening {
shared_t!("screening.nip05_label")
}
})
.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 {
shared_t!("screening.nip05_ok")
} else {
shared_t!("screening.nip05_failed")
}
} else {
shared_t!("screening.nip05_empty")
}
}),
),
),
)
.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(shared_t!("screening.mutual_label"))
.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 {
shared_t!("screening.mutual", u = total_mutuals)
} else {
shared_t!("screening.no_mutual")
}
}),
),
),
),
)
}
}
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

@@ -1,8 +1,8 @@
use std::time::Duration;
use anyhow::{anyhow, Error};
use global::constants::{NEW_MESSAGE_ID, NIP17_RELAYS};
use global::nostr_client;
use global::constants::NIP17_RELAYS;
use global::{css, nostr_client};
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
@@ -10,31 +10,32 @@ use gpui::{
TextAlign, UniformList, Window,
};
use i18n::{shared_t, t};
use identity::Identity;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use registry::Registry;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::button::{Button, 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 init(kind: Kind, window: &mut Window, cx: &mut App) -> Entity<SetupRelay> {
cx.new(|cx| SetupRelay::new(kind, window, cx))
}
pub fn relay_button() -> impl IntoElement {
pub fn setup_nip17_relay<T>(label: T) -> impl IntoElement
where
T: Into<SharedString>,
{
div().child(
Button::new("dm-relays")
Button::new("setup-relays")
.icon(IconName::Info)
.label(t!("relays.button_label"))
.label(label)
.warning()
.xsmall()
.rounded(ButtonRounded::Full)
.rounded()
.on_click(move |_, window, cx| {
let title = SharedString::new(t!("relays.modal_title"));
let view = cx.new(|cx| MessagingRelays::new(window, cx));
let view = cx.new(|cx| SetupRelay::new(Kind::InboxRelays, window, cx));
let weak_view = view.downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
@@ -42,7 +43,7 @@ pub fn relay_button() -> impl IntoElement {
modal
.confirm()
.title(title.clone())
.title(shared_t!("relays.modal"))
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
@@ -59,60 +60,41 @@ pub fn relay_button() -> impl IntoElement {
)
}
pub struct MessagingRelays {
pub struct SetupRelay {
input: Entity<InputState>,
relays: Vec<RelayUrl>,
error: Option<SharedString>,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
_subscriptions: SmallVec<[Subscription; 1]>,
_tasks: SmallVec<[Task<()>; 1]>,
}
impl MessagingRelays {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
impl SetupRelay {
pub fn new(kind: Kind, window: &mut Window, cx: &mut Context<Self>) -> Self {
let identity = Registry::read_global(cx).identity(cx).public_key();
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
let mut subscriptions = smallvec![];
let mut tasks = 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 load_relay = 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);
let filter = Filter::new().kind(kind).author(identity).limit(1);
if let Some(event) = client.database().query(filter).await?.first() {
let relays = event
let relays: Vec<RelayUrl> = event
.tags
.filter(TagKind::Relay)
.filter_map(|tag| RelayUrl::parse(tag.content()?).ok())
.collect::<Vec<_>>();
.iter()
.filter_map(|tag| tag.as_standardized())
.filter_map(|tag| {
if let TagStandard::RelayMetadata { relay_url, .. } = tag {
Some(relay_url.to_owned())
} else if let TagStandard::Relay(url) = tag {
Some(url.to_owned())
} else {
None
}
})
.collect();
Ok(relays)
} else {
@@ -120,16 +102,39 @@ impl MessagingRelays {
}
});
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();
tasks.push(
// Load user's relays in the local database
cx.spawn_in(window, async move |this, cx| {
if let Ok(relays) = load_relay.await {
this.update(cx, |this, cx| {
this.relays = 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: vec![],
error: None,
_subscriptions: subscriptions,
_tasks: tasks,
}
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -192,33 +197,37 @@ impl MessagingRelays {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(
relays
.iter()
.map(|relay| Tag::relay(relay.clone()))
.collect_vec(),
);
let tags: Vec<Tag> = relays
.iter()
.map(|relay| Tag::relay(relay.clone()))
.collect();
let event = EventBuilder::new(Kind::InboxRelays, "")
.tags(tags)
.build(public_key)
.sign(&signer)
.await?;
// Set messaging relays
client.send_event_builder(builder).await?;
client.send_event(&event).await?;
// Connect to messaging relays
for relay in relays.into_iter() {
_ = client.add_relay(&relay).await;
_ = client.connect_relay(&relay).await;
for relay in relays.iter() {
_ = client.add_relay(relay).await;
_ = client.connect_relay(relay).await;
}
let id = SubscriptionId::new(NEW_MESSAGE_ID);
let new_messages = Filter::new()
.kind(Kind::GiftWrap)
.pubkey(public_key)
.limit(0);
// Fetch gift wrap events
let sub_id = css().gift_wrap_sub_id.clone();
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
// Close old subscriptions
client.unsubscribe(&id).await;
// Subscribe to new messages
client.subscribe_with_id(id, new_messages, None).await?;
if client
.subscribe_with_id_to(relays.clone(), sub_id, filter, None)
.await
.is_ok()
{
log::info!("Subscribed to messages in: {relays:?}");
};
Ok(())
});
@@ -227,20 +236,13 @@ impl MessagingRelays {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
Identity::global(cx).update(cx, |this, cx| {
this.verify_dm_relays(window, cx);
});
// Close the current modal
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();
this.update_in(cx, |this, window, cx| {
this.set_error(e.to_string(), window, cx);
})
.ok();
}
@@ -304,11 +306,11 @@ impl MessagingRelays {
.justify_center()
.text_sm()
.text_align(TextAlign::Center)
.child(SharedString::new(t!("relays.add_some_relays")))
.child(shared_t!("relays.help_text"))
}
}
impl Render for MessagingRelays {
impl Render for SetupRelay {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
@@ -344,7 +346,7 @@ impl Render for MessagingRelays {
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(shared_t!("relays.recommended")),
.child(shared_t!("common.recommended")),
)
.child(h_flex().gap_1().children({
NIP17_RELAYS.iter().map(|&relay| {

View File

@@ -2,8 +2,8 @@ use std::rc::Rc;
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::*;
@@ -11,10 +11,9 @@ 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;
use ui::skeleton::Skeleton;
use ui::{h_flex, ContextModal, StyledExt};
use crate::views::screening;
@@ -22,60 +21,114 @@ use crate::views::screening;
#[derive(IntoElement)]
pub struct RoomListItem {
ix: usize,
base: Div,
room_id: u64,
public_key: PublicKey,
name: SharedString,
avatar: SharedString,
created_at: SharedString,
kind: RoomKind,
room_id: Option<u64>,
public_key: Option<PublicKey>,
name: Option<SharedString>,
avatar: Option<SharedString>,
created_at: Option<SharedString>,
kind: Option<RoomKind>,
#[allow(clippy::type_complexity)]
handler: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>,
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl RoomListItem {
pub fn new(
ix: usize,
room_id: u64,
public_key: PublicKey,
name: SharedString,
avatar: SharedString,
created_at: SharedString,
kind: RoomKind,
) -> Self {
pub fn new(ix: usize) -> Self {
Self {
ix,
public_key,
room_id,
name,
avatar,
created_at,
kind,
base: h_flex().h_9().w_full().px_1p5(),
handler: Rc::new(|_, _, _| {}),
room_id: None,
public_key: None,
name: None,
avatar: None,
created_at: None,
kind: None,
handler: None,
}
}
pub fn room_id(mut self, room_id: u64) -> Self {
self.room_id = Some(room_id);
self
}
pub fn public_key(mut self, public_key: PublicKey) -> Self {
self.public_key = Some(public_key);
self
}
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
self.name = Some(name.into());
self
}
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
self.avatar = Some(avatar.into());
self
}
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
self.created_at = Some(created_at.into());
self
}
pub fn kind(mut self, kind: RoomKind) -> Self {
self.kind = Some(kind);
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.handler = Rc::new(handler);
self.handler = Some(Rc::new(handler));
self
}
}
impl RenderOnce for RoomListItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let public_key = self.public_key;
let room_id = self.room_id;
let kind = self.kind;
let handler = self.handler.clone();
let hide_avatar = AppSettings::get_hide_user_avatars(cx);
let require_screening = AppSettings::get_screening(cx);
self.base
let (
Some(public_key),
Some(room_id),
Some(name),
Some(avatar),
Some(created_at),
Some(kind),
Some(handler),
) = (
self.public_key,
self.room_id,
self.name,
self.avatar,
self.created_at,
self.kind,
self.handler,
)
else {
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()),
);
};
h_flex()
.id(self.ix)
.h_9()
.w_full()
.px_1p5()
.gap_2()
.text_sm()
.rounded(cx.theme().radius)
@@ -86,7 +139,7 @@ impl RenderOnce for RoomListItem {
.size_6()
.rounded_full()
.overflow_hidden()
.child(Avatar::new(self.avatar).size(rems(1.5))),
.child(Avatar::new(avatar).size(rems(1.5))),
)
})
.child(
@@ -102,20 +155,16 @@ impl RenderOnce for RoomListItem {
.text_ellipsis()
.truncate()
.font_medium()
.child(self.name),
.child(name),
)
.child(
div()
.flex_shrink_0()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(self.created_at),
.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))
.on_click(move |event, window, cx| {
handler(event, window, cx);

View File

@@ -4,33 +4,32 @@ use std::time::Duration;
use anyhow::{anyhow, Error};
use common::debounced_delay::DebouncedDelay;
use common::display::TextUtils;
use common::display::{ReadableTimestamp, TextUtils};
use global::constants::{BOOTSTRAP_RELAYS, SEARCH_RELAYS};
use global::nostr_client;
use global::{css, nostr_client, UnwrappingStatus};
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString,
Styled, Subscription, Task, Window,
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
RetainAllImageCache, SharedString, Styled, Subscription, Task, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use i18n::{shared_t, t};
use itertools::Itertools;
use list_item::RoomListItem;
use nostr_sdk::prelude::*;
use registry::room::{Room, RoomKind};
use registry::{Registry, RegistrySignal};
use registry::{Registry, RegistryEvent};
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::input::{InputEvent, InputState, TextInput};
use ui::popup_menu::PopupMenu;
use ui::skeleton::Skeleton;
use ui::{v_flex, ContextModal, IconName, Selectable, Sizable, StyledExt};
use ui::popup_menu::{PopupMenu, PopupMenuExt};
use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Selectable, Sizable, StyledExt};
use crate::actions::{RelayStatus, Reload};
mod list_item;
@@ -72,18 +71,17 @@ impl Sidebar {
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 find_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("sidebar.search_label")));
let chats = Registry::global(cx);
let registry = Registry::global(cx);
let mut subscriptions = smallvec![];
subscriptions.push(cx.subscribe_in(
&chats,
&registry,
window,
move |this, _chats, event, _window, cx| {
if let RegistrySignal::NewRequest(kind) = event {
move |this, _, event, _window, cx| {
if let RegistryEvent::NewRequest(kind) = event {
this.indicator.update(cx, |this, cx| {
*this = Some(kind.to_owned());
cx.notify();
@@ -195,11 +193,8 @@ impl Sidebar {
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();
this.update_in(cx, |this, window, cx| {
this.search(window, cx);
})
.ok();
})
@@ -212,13 +207,7 @@ impl Sidebar {
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 identity = Registry::read_global(cx).identity(cx).public_key();
let query = query.to_owned();
let query_cloned = query.clone();
@@ -236,18 +225,15 @@ impl Sidebar {
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();
this.update_in(cx, |this, window, 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);
}
if rooms.is_empty() {
window.push_notification(msg, cx);
}
this.results(rooms, true, window, cx);
})
.ok();
this.results(rooms, true, window, cx);
})
.ok();
}
@@ -263,12 +249,10 @@ impl Sidebar {
}
// 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);
})
this.update_in(cx, |this, window, cx| {
window.push_notification(e.to_string(), cx);
this.set_finding(false, window, cx);
this.set_cancel_handle(None, cx);
})
.ok();
}
@@ -278,13 +262,7 @@ impl Sidebar {
}
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 identity = Registry::read_global(cx).identity(cx).public_key();
let address = query.to_owned();
let task = Tokio::spawn(cx, async move {
@@ -338,12 +316,7 @@ impl Sidebar {
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 identity = Registry::read_global(cx).identity(cx).public_key();
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
@@ -547,58 +520,86 @@ impl Sidebar {
});
}
fn open_loading_modal(&self, window: &mut Window, cx: &mut Context<Self>) {
let title = SharedString::new(t!("sidebar.loading_modal_title"));
let text_1 = SharedString::new(t!("sidebar.loading_modal_body_1"));
let text_2 = SharedString::new(t!("sidebar.loading_modal_body_2"));
let desc = SharedString::new(t!("sidebar.loading_modal_description"));
window.open_modal(cx, move |this, _window, cx| {
this.show_close(true)
.keyboard(true)
.title(title.clone())
.child(
v_flex()
.pb_4()
.gap_2()
.child(
div()
.flex()
.flex_col()
.gap_2()
.text_sm()
.child(text_1.clone())
.child(text_2.clone()),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(desc.clone()),
),
)
fn on_reload(&mut self, _ev: &Reload, window: &mut Window, cx: &mut Context<Self>) {
Registry::global(cx).update(cx, |this, cx| {
this.load_rooms(window, cx);
});
window.push_notification(t!("common.refreshed"), cx);
}
fn skeletons(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
(0..total).map(|_| {
div()
.h_9()
.w_full()
.px_1p5()
.flex()
.items_center()
.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()),
)
fn on_manage(&mut self, _ev: &RelayStatus, window: &mut Window, cx: &mut Context<Self>) {
let task: Task<Result<Vec<Relay>, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let css = css();
let subscription = client.subscription(&css.gift_wrap_sub_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(shared_t!("manage_relays.modal"))
.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(shared_t!("manage_relays.time", t = time)),
),
);
}
items
}))
});
}
fn list_items(
@@ -621,17 +622,17 @@ impl Sidebar {
});
items.push(
RoomListItem::new(
ix,
room_id,
this.members[0],
this.display_name(cx),
this.display_image(proxy, cx),
this.ago(),
this.kind,
)
.on_click(handler),
RoomListItem::new(ix)
.room_id(room_id)
.name(this.display_name(cx))
.avatar(this.display_image(proxy, cx))
.created_at(this.created_at.to_ago())
.public_key(this.members[0])
.kind(this.kind)
.on_click(handler),
)
} else {
items.push(RoomListItem::new(ix));
}
}
@@ -668,6 +669,7 @@ impl Focusable for Sidebar {
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.unwrapping_status.read(cx) != &UnwrappingStatus::Complete;
// Get rooms from either search results or the chat registry
let rooms = if let Some(results) = self.local_result.read(cx).as_ref() {
@@ -683,7 +685,17 @@ impl Render for Sidebar {
}
};
// 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()
@@ -706,7 +718,7 @@ impl Render for Sidebar {
.suffix(
Button::new("find")
.icon(IconName::Search)
.tooltip(t!("sidebar.press_enter_to_search"))
.tooltip(t!("sidebar.search_tooltip"))
.transparent()
.small(),
),
@@ -722,86 +734,133 @@ impl Render for Sidebar {
.overflow_y_hidden()
.child(
div()
.flex_none()
.px_1()
.w_full()
.h_9()
.flex()
.items_center()
.justify_between()
.h_flex()
.gap_2()
.flex_none()
.child(
div()
.flex()
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()
.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(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()
.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()
.gap_2()
.text_xs()
.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);
})),
Button::new("option")
.icon(IconName::Ellipsis)
.xsmall()
.ghost()
.rounded()
.popup_menu(move |this, _window, _cx| {
this.menu(
t!("sidebar.reload_menu"),
Box::new(Reload),
)
.menu(
t!("sidebar.status_menu"),
Box::new(RelayStatus),
)
}),
),
),
)
.when(registry.loading, |this| {
this.child(
div()
.flex_1()
.flex()
.flex_col()
.gap_1()
.children(self.skeletons(1)),
)
.when(!loading && total_rooms == 0, |this| {
this.map(|this| {
if self.filter(&RoomKind::Ongoing, cx) {
this.child(
v_flex()
.py_2()
.gap_1p5()
.items_center()
.justify_center()
.text_center()
.child(
div()
.text_sm()
.font_semibold()
.line_height(relative(1.25))
.child(shared_t!("sidebar.no_conversations")),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.line_height(relative(1.25))
.child(shared_t!("sidebar.no_conversations_label")),
),
)
} else {
this.child(
v_flex()
.py_2()
.gap_1p5()
.items_center()
.justify_center()
.text_center()
.child(
div()
.text_sm()
.font_semibold()
.line_height(relative(1.25))
.child(shared_t!("sidebar.no_requests")),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.line_height(relative(1.25))
.child(shared_t!("sidebar.no_requests_label")),
),
)
}
})
})
.child(
uniform_list(
"rooms",
rooms.len(),
total_rooms,
cx.processor(move |this, range, _window, cx| {
this.list_items(&rooms, range, cx)
}),
@@ -809,59 +868,5 @@ impl Render for Sidebar {
.h_full(),
),
)
.when(registry.loading, |this| {
let title = SharedString::new(t!("sidebar.retrieving_messages"));
let desc = SharedString::new(t!("sidebar.retrieving_messages_description"));
this.child(
div().absolute().bottom_3().px_3().w_full().child(
div()
.p_1()
.w_full()
.rounded_full()
.flex()
.items_center()
.justify_between()
.bg(cx.theme().panel_background)
.shadow_sm()
// Loading
.child(div().flex_shrink_0().pl_1().child(Indicator::new().small()))
// Title
.child(
v_flex()
.flex_1()
.items_center()
.justify_center()
.text_center()
.child(
div()
.text_sm()
.font_semibold()
.line_height(relative(1.2))
.child(title.clone()),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(desc.clone()),
),
)
// Info button
.child(
Button::new("help")
.icon(IconName::Info)
.tooltip(t!("sidebar.why_seeing_this_tooltip"))
.small()
.ghost()
.rounded(ButtonRounded::Full)
.flex_shrink_0()
.on_click(cx.listener(move |this, _, window, cx| {
this.open_loading_modal(window, cx)
})),
),
),
)
})
}
}

View File

@@ -1,131 +0,0 @@
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);
});
}),
),
),
)
}
}

View File

@@ -1,6 +1,6 @@
use std::time::Duration;
use common::display::DisplayProfile;
use common::display::ReadableProfile;
use common::nip05::nip05_verify;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
@@ -9,44 +9,35 @@ use gpui::{
ParentElement, Render, SharedString, Styled, Task, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use i18n::{shared_t, t};
use nostr_sdk::prelude::*;
use registry::Registry;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
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)
cx.new(|cx| UserProfile::new(public_key, window, cx))
}
pub struct UserProfile {
public_key: PublicKey,
profile: Profile,
followed: bool,
verified: bool,
copied: bool,
_tasks: SmallVec<[Task<()>; 1]>,
}
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 new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let registry = Registry::read_global(cx);
let identity = registry.identity(cx).public_key();
let profile = registry.get_person(&public_key, cx);
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 mut tasks = smallvec![];
let check_follow: Task<bool> = cx.background_spawn(async move {
let client = nostr_client();
@@ -59,7 +50,7 @@ impl UserProfile {
client.database().count(filter).await.unwrap_or(0) >= 1
});
let verify_nip05 = if let Some(address) = self.address(cx) {
let verify_nip05 = if let Some(address) = profile.metadata().nip05 {
Some(Tokio::spawn(cx, async move {
nip05_verify(public_key, &address).await.unwrap_or(false)
}))
@@ -67,41 +58,46 @@ impl UserProfile {
None
};
cx.spawn_in(window, async move |this, cx| {
let followed = check_follow.await;
tasks.push(
// Load user profile data
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 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();
// 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();
}),
);
Self {
profile,
followed: false,
verified: false,
copied: false,
_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 copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.public_key.to_bech32();
let Ok(bech32) = self.profile.public_key().to_bech32();
let item = ClipboardItem::new_string(bech32);
cx.write_to_clipboard(item);
@@ -132,9 +128,8 @@ impl UserProfile {
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 Ok(bech32) = self.profile.public_key().to_bech32();
let shared_bech32 = SharedString::new(bech32);
v_flex()
@@ -145,14 +140,14 @@ impl Render for UserProfile {
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(4.)))
.child(Avatar::new(self.profile.avatar_url(proxy)).size(rems(4.)))
.child(
v_flex()
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(profile.display_name()),
.child(self.profile.display_name()),
)
.when_some(self.address(cx), |this, address| {
this.child(
@@ -187,7 +182,7 @@ impl Render for UserProfile {
.bg(cx.theme().elevated_surface_background)
.text_xs()
.font_semibold()
.child(SharedString::new(t!("profile.unknown"))),
.child(shared_t!("profile.unknown")),
)
}),
)
@@ -239,7 +234,7 @@ impl Render for UserProfile {
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("profile.label_bio"))),
.child(shared_t!("profile.label_bio")),
)
.child(
div()
@@ -247,7 +242,7 @@ impl Render for UserProfile {
.rounded_md()
.bg(cx.theme().elevated_surface_background)
.child(
profile
self.profile
.metadata()
.about
.unwrap_or(t!("profile.no_bio").to_string()),

View File

@@ -1,12 +1,13 @@
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 +15,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 +25,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,16 +40,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 title(&self, cx: &App) -> AnyElement {
div()
.child(
svg()
.path("brand/coop.svg")
.size_4()
.text_color(cx.theme().element_background),
)
.into_any_element()
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
@@ -76,11 +76,10 @@ impl Render for Welcome {
.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 +87,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("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");
}),
),
),
)
}

View File

@@ -5,11 +5,10 @@ edition.workspace = true
publish.workspace = true
[dependencies]
nostr-connect.workspace = true
nostr-sdk.workspace = true
dirs.workspace = true
smol.workspace = true
futures.workspace = true
flume.workspace = true
log.workspace = true
anyhow.workspace = true

View File

@@ -4,20 +4,20 @@ pub const APP_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZX
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";
pub const ACCOUNT_IDENTIFIER: &str = "coop:user";
pub const SETTINGS_IDENTIFIER: &str = "coop:settings";
/// Bootstrap Relays.
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nostr.Wine",
"wss://relay.nos.social",
"wss://user.kindpag.es",
"wss://purplepag.es",
];
/// Search Relays.
pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.nostr.band"];
pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"];
/// NIP65 Relays. Used for new account
pub const NIP65_RELAYS: [&str; 4] = [
@@ -28,30 +28,31 @@ pub const NIP65_RELAYS: [&str; 4] = [
];
/// Messaging Relays. Used for new account
pub const NIP17_RELAYS: [&str; 2] = ["wss://nip17.com", "wss://relay.0xchat.com"];
pub const NIP17_RELAYS: [&str; 2] = ["wss://nip17.com", "wss://auth.nostr1.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;
/// Unique ID for new message subscription.
pub const NEW_MESSAGE_ID: &str = "listen_new_giftwraps";
/// Unique ID for all messages subscription.
pub const ALL_MESSAGES_ID: &str = "listen_all_giftwraps";
/// Unique ID for all newest messages subscription.
pub const ALL_NEWEST_MESSAGES_ID: &str = "listen_all_newest_giftwraps";
/// Default timeout (in seconds) for Nostr Connect (Bunker)
pub const BUNKER_TIMEOUT: u64 = 30;
/// Total metadata requests will be grouped.
pub const METADATA_BATCH_LIMIT: usize = 100;
/// Maximum timeout for grouping metadata requests.
pub const METADATA_BATCH_TIMEOUT: u64 = 400;
/// Default width for all modals.
pub const DEFAULT_MODAL_WIDTH: f32 = 420.;
/// Maximum timeout for grouping metadata requests. (milliseconds)
pub const METADATA_BATCH_TIMEOUT: u64 = 300;
/// Default width of the sidebar.
pub const DEFAULT_SIDEBAR_WIDTH: f32 = 280.;
pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.;
/// Image Resize Service
pub const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl";

View File

@@ -1,39 +1,211 @@
use std::collections::{HashMap, HashSet};
use std::sync::atomic::AtomicBool;
use std::sync::OnceLock;
use std::time::Duration;
use nostr_connect::prelude::*;
use flume::{Receiver, Sender};
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, PartialEq, Eq, PartialOrd, Ord)]
pub struct AuthRequest {
pub url: RelayUrl,
pub challenge: String,
pub sending: bool,
}
impl AuthRequest {
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
Self {
challenge: challenge.into(),
sending: false,
url,
}
}
}
#[derive(Debug, Clone)]
pub enum NostrSignal {
/// Received a new metadata event from Relay Pool
Metadata(Event),
pub enum Notice {
RelayFailed(RelayUrl),
AuthFailed(RelayUrl),
Custom(String),
}
/// Received a new gift wrap event from Relay Pool
GiftWrap(Event),
impl Notice {
pub fn as_str(&self) -> String {
match self {
Notice::AuthFailed(url) => format!("Authenticate failed for relay {url}"),
Notice::RelayFailed(url) => format!("Failed to connect the relay {url}"),
Notice::Custom(msg) => msg.into(),
}
}
}
/// Finished processing all gift wrap events
Finish,
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum UnwrappingStatus {
#[default]
Initialized,
Processing,
Complete,
}
/// Partially finished processing all gift wrap events
PartialFinish,
/// Signals sent through the global event channel to notify UI
#[derive(Debug)]
pub enum SignalKind {
/// A signal to notify UI that the client's signer has been set
SignerSet(PublicKey),
/// Receives EOSE response from relay pool
Eose(SubscriptionId),
/// A signal to notify UI that the client's signer has been unset
SignerUnset,
/// Notice from Relay Pool
Notice(String),
/// A signal to notify UI that the relay requires authentication
Auth(AuthRequest),
/// A signal to notify UI that the browser proxy service is down
ProxyDown,
/// A signal to notify UI that a new profile has been received
NewProfile(Profile),
/// A signal to notify UI that a new gift wrap event has been received
NewMessage((EventId, Event)),
/// A signal to notify UI that no DM relays for current user was found
RelaysNotFound,
/// A signal to notify UI that gift wrap status has changed
GiftWrapStatus(UnwrappingStatus),
/// A signal to notify UI that there are errors or notices occurred
Notice(Notice),
}
#[derive(Debug)]
pub struct Signal {
rx: Receiver<SignalKind>,
tx: Sender<SignalKind>,
}
impl Default for Signal {
fn default() -> Self {
Self::new()
}
}
impl Signal {
pub fn new() -> Self {
let (tx, rx) = flume::bounded::<SignalKind>(2048);
Self { rx, tx }
}
pub fn receiver(&self) -> &Receiver<SignalKind> {
&self.rx
}
pub async fn send(&self, kind: SignalKind) {
if let Err(e) = self.tx.send_async(kind).await {
log::error!("Failed to send signal: {e}");
}
}
}
#[derive(Debug)]
pub struct Ingester {
rx: Receiver<PublicKey>,
tx: Sender<PublicKey>,
}
impl Default for Ingester {
fn default() -> Self {
Self::new()
}
}
impl Ingester {
pub fn new() -> Self {
let (tx, rx) = flume::bounded::<PublicKey>(1024);
Self { rx, tx }
}
pub fn receiver(&self) -> &Receiver<PublicKey> {
&self.rx
}
pub async fn send(&self, public_key: PublicKey) {
if let Err(e) = self.tx.send_async(public_key).await {
log::error!("Failed to send public key: {e}");
}
}
}
/// A simple storage to store all states that using across the application.
#[derive(Debug)]
pub struct CoopSimpleStorage {
pub init_at: Timestamp,
pub last_used_at: Option<Timestamp>,
pub is_first_run: AtomicBool,
pub gift_wrap_sub_id: SubscriptionId,
pub gift_wrap_processing: AtomicBool,
pub auto_close_opts: Option<SubscribeAutoCloseOptions>,
pub seen_on_relays: RwLock<HashMap<EventId, HashSet<RelayUrl>>>,
pub sent_ids: RwLock<HashSet<EventId>>,
pub resent_ids: RwLock<Vec<Output<EventId>>>,
pub resend_queue: RwLock<HashMap<EventId, RelayUrl>>,
pub signal: Signal,
pub ingester: Ingester,
}
impl Default for CoopSimpleStorage {
fn default() -> Self {
Self::new()
}
}
impl CoopSimpleStorage {
pub fn new() -> Self {
let init_at = Timestamp::now();
let first_run = first_run();
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let signal = Signal::default();
let ingester = Ingester::default();
Self {
init_at,
signal,
ingester,
last_used_at: None,
is_first_run: AtomicBool::new(first_run),
gift_wrap_sub_id: SubscriptionId::new("inbox"),
gift_wrap_processing: AtomicBool::new(false),
auto_close_opts: Some(opts),
seen_on_relays: RwLock::new(HashMap::new()),
sent_ids: RwLock::new(HashSet::new()),
resent_ids: RwLock::new(Vec::new()),
resend_queue: RwLock::new(HashMap::new()),
}
}
}
static NOSTR_CLIENT: OnceLock<Client> = OnceLock::new();
static FIRST_RUN: OnceLock<bool> = OnceLock::new();
static COOP_SIMPLE_STORAGE: OnceLock<CoopSimpleStorage> = OnceLock::new();
pub fn nostr_client() -> &'static Client {
NOSTR_CLIENT.get_or_init(|| {
@@ -47,44 +219,30 @@ pub fn nostr_client() -> &'static Client {
let lmdb = NostrLMDB::open(nostr_file()).expect("Database is NOT initialized");
let opts = ClientOptions::new()
// Coop isn't social client,
// but it needs this option because it needs user's NIP65 Relays to fetch NIP17 Relays.
.gossip(true)
// TODO: Coop should handle authentication by itself
.automatic_authentication(true)
// Sleep after idle for 5 seconds
.automatic_authentication(false)
.verify_subscriptions(false)
.sleep_when_idle(SleepWhenIdle::Enabled {
timeout: Duration::from_secs(10),
timeout: Duration::from_secs(30),
});
ClientBuilder::default().database(lmdb).opts(opts).build()
})
}
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
}
})
pub fn css() -> &'static CoopSimpleStorage {
COOP_SIMPLE_STORAGE.get_or_init(CoopSimpleStorage::new)
}
pub async fn set_all_gift_wraps_fetched() {
let flag = support_dir().join(".fetched");
fn first_run() -> bool {
let flag = support_dir().join(format!(".{}-first_run", env!("CARGO_PKG_VERSION")));
if !flag.exists() && smol::fs::write(&flag, "").await.is_err() {
log::error!("Failed to create full run flag");
if !flag.exists() {
if std::fs::write(&flag, "").is_err() {
return false;
}
true // First run
} else {
false // Not first run
}
}
pub fn is_gift_wraps_fetch_complete() -> bool {
let flag = support_dir().join(".fetched");
flag.exists()
}

View File

@@ -1,20 +0,0 @@
[package]
name = "identity"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
ui = { path = "../ui" }
global = { path = "../global" }
common = { path = "../common" }
client_keys = { path = "../client_keys" }
settings = { path = "../settings" }
nostr-sdk.workspace = true
nostr-connect.workspace = true
oneshot.workspace = true
gpui.workspace = true
anyhow.workspace = true
log.workspace = true
smallvec.workspace = true

View File

@@ -1,698 +0,0 @@
use std::time::Duration;
use anyhow::{anyhow, Error};
use client_keys::ClientKeys;
use common::handle_auth::CoopAuthUrlHandler;
use global::constants::{
ACCOUNT_D, ALL_MESSAGES_ID, ALL_NEWEST_MESSAGES_ID, NEW_MESSAGE_ID, NIP17_RELAYS, NIP65_RELAYS,
NOSTR_CONNECT_TIMEOUT,
};
use global::{is_gift_wraps_fetch_complete, 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,
relay_ready: 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,
relay_ready: None,
need_backup: 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);
// Unset signer
client.unset_signer().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();
// Close 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
})
.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<Option<SecretKey>> =
cx.background_spawn(async move { enc.decrypt(&password).ok() });
cx.spawn_in(window, async move |this, cx| {
if let Some(secret) = task.await {
cx.update(|window, cx| {
window.close_modal(cx);
// Update user's signer with decrypted secret key
this.update(cx, |this, cx| {
this.set_signer(Keys::new(secret), window, cx);
})
.ok();
})
.ok();
} else {
_ = error.update(cx, |this, cx| {
*this = Some("Invalid password".into());
cx.notify();
});
}
})
.detach();
}
/// Sets a new signer for the client and updates user identity
pub fn set_signer<S>(&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
Self::subscribe(client, 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?;
// Subscribe for user metadata
Self::subscribe(client, 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();
}
pub fn verify_dm_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let Some(public_key) = self.public_key() else {
return;
};
let task: Task<bool> = cx.background_spawn(async move {
let client = nostr_client();
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
let Ok(events) = client.database().query(filter).await else {
return false;
};
let Some(event) = events.first() else {
return false;
};
let relays: Vec<RelayUrl> = event
.tags
.filter(TagKind::Relay)
.filter_map(|tag| RelayUrl::parse(tag.content()?).ok())
.collect();
!relays.is_empty()
});
cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| {
this.relay_ready = Some(result);
cx.notify();
})
.ok();
})
.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();
// Run verify user's dm relays task
cx.defer_in(window, |this, window, cx| {
this.verify_dm_relays(window, cx);
});
}
/// 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()
}
pub fn relay_ready(&self) -> Option<bool> {
self.relay_ready
}
/// Returns true if the identity is currently logging in
pub fn logging_in(&self) -> bool {
self.logging_in
}
/// 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();
}
pub(crate) async fn subscribe(client: &Client, public_key: PublicKey) -> Result<(), Error> {
let all_messages = SubscriptionId::new(ALL_MESSAGES_ID);
let all_newest_messages = SubscriptionId::new(ALL_NEWEST_MESSAGES_ID);
let new_messages = SubscriptionId::new(NEW_MESSAGE_ID);
// Subscription options
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
// Get the gift wraps fetch status
let is_completed = is_gift_wraps_fetch_complete();
client
.subscribe(
Filter::new()
.author(public_key)
.kinds(vec![Kind::Metadata, Kind::ContactList, Kind::RelayList])
.since(Timestamp::now()),
None,
)
.await?;
client
.subscribe(
Filter::new()
.kinds(vec![Kind::Metadata, Kind::ContactList, Kind::RelayList])
.author(public_key)
.limit(10),
Some(opts),
)
.await?;
client
.subscribe_with_id(
new_messages,
Filter::new()
.kind(Kind::GiftWrap)
.pubkey(public_key)
.limit(0),
None,
)
.await?;
if is_completed {
let week_ago: u64 = 7 * 24 * 60 * 60;
let since = Timestamp::from_secs(Timestamp::now().as_u64() - week_ago);
let filter = Filter::new()
.pubkey(public_key)
.kind(Kind::GiftWrap)
.since(since);
client
.subscribe_with_id(all_newest_messages, filter, Some(opts))
.await?;
} else {
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
client
.subscribe_with_id(all_messages, filter, Some(opts))
.await?;
};
log::info!("Getting all user's metadata and messages...");
Ok(())
}
}

View File

@@ -9,17 +9,13 @@ common = { path = "../common" }
global = { path = "../global" }
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
fuzzy-matcher = "0.3.7"

View File

@@ -1,14 +1,12 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::collections::{HashMap, HashSet};
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 global::{nostr_client, UnwrappingStatus};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, WeakEntity, Window};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use room::RoomKind;
@@ -20,8 +18,6 @@ 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);
}
@@ -31,7 +27,7 @@ struct GlobalRegistry(Entity<Registry>);
impl Global for GlobalRegistry {}
#[derive(Debug)]
pub enum RegistrySignal {
pub enum RegistryEvent {
Open(WeakEntity<Room>),
Close(u64),
NewRequest(RoomKind),
@@ -43,19 +39,19 @@ pub struct Registry {
pub rooms: Vec<Entity<Room>>,
/// Collection of all persons (user profiles)
pub persons: BTreeMap<PublicKey, Entity<Profile>>,
pub persons: HashMap<PublicKey, Entity<Profile>>,
/// Indicates if rooms are currently being loaded
///
/// Always equal to `true` when the app starts
pub loading: bool,
/// Status of the unwrapping process
pub unwrapping_status: Entity<UnwrappingStatus>,
/// Subscriptions for observing changes
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
/// Public Key of the current user
pub identity: Option<PublicKey>,
/// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 1]>,
}
impl EventEmitter<RegistrySignal> for Registry {}
impl EventEmitter<RegistryEvent> for Registry {}
impl Registry {
/// Retrieve the Global Registry state
@@ -75,68 +71,69 @@ impl Registry {
/// Create a new Registry instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
let unwrapping_status = cx.new(|_| UnwrappingStatus::default());
let mut tasks = 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);
}));
let load_local_persons: Task<Result<Vec<Profile>, Error>> =
cx.background_spawn(async move {
let client = nostr_client();
let filter = Filter::new().kind(Kind::Metadata).limit(200);
let events = client.database().query(filter).await?;
let mut profiles = vec![];
// 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);
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);
}
state.update(cx, |this, cx| {
this.set_persons_from_task(task, cx);
Ok(profiles)
});
}));
tasks.push(
// Load all user profiles from the database when the Registry is created
cx.spawn(async move |this, cx| {
if let Ok(profiles) = load_local_persons.await {
this.update(cx, |this, cx| {
this.set_persons(profiles, cx);
})
.ok();
}
}),
);
Self {
unwrapping_status,
rooms: vec![],
persons: BTreeMap::new(),
loading: true,
subscriptions,
persons: HashMap::new(),
identity: None,
_tasks: tasks,
}
}
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();
/// Returns the identity of the user.
///
/// WARNING: This method will panic if user is not logged in.
pub fn identity(&self, cx: &App) -> Profile {
self.get_person(&self.identity.unwrap(), cx)
}
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)
})
/// Sets the identity of the user.
pub fn set_identity(&mut self, identity: PublicKey, cx: &mut Context<Self>) {
self.identity = Some(identity);
cx.notify();
}
/// Insert batch of persons
pub fn set_persons(&mut self, profiles: Vec<Profile>, cx: &mut Context<Self>) {
for profile in profiles.into_iter() {
self.persons
.insert(profile.public_key(), cx.new(|_| profile));
}
cx.notify();
}
/// Get single person
pub fn get_person(&self, public_key: &PublicKey, cx: &App) -> Profile {
self.persons
.get(public_key)
@@ -145,6 +142,7 @@ impl Registry {
.unwrap_or(Profile::new(public_key.to_owned(), Metadata::default()))
}
/// Get group of persons
pub fn get_group_person(&self, public_keys: &[PublicKey], cx: &App) -> Vec<Profile> {
let mut profiles = vec![];
@@ -156,21 +154,20 @@ impl Registry {
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;
};
/// Insert or update a person
pub fn insert_or_update_person(&mut self, profile: Profile, cx: &mut App) {
let public_key = profile.public_key();
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)));
match self.persons.get(&public_key) {
Some(person) => {
person.update(cx, |this, cx| {
*this = profile;
cx.notify();
});
}
None => {
self.persons.insert(public_key, cx.new(|_| profile));
}
}
}
@@ -209,7 +206,7 @@ impl Registry {
/// 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));
cx.emit(RegistryEvent::Close(id));
}
}
@@ -244,8 +241,24 @@ impl Registry {
}
/// Set the loading status of the registry.
pub fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
pub fn set_unwrapping_status(&mut self, status: UnwrappingStatus, cx: &mut Context<Self>) {
self.unwrapping_status.update(cx, |this, cx| {
*this = status;
cx.notify();
});
}
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
// Reset the unwrapping status
self.set_unwrapping_status(UnwrappingStatus::default(), cx);
// Clear the current identity
self.identity = None;
// Clear all current rooms
self.rooms.clear();
cx.notify();
}
@@ -254,12 +267,13 @@ impl Registry {
log::info!("Starting to load chat rooms...");
// Get the contact bypass setting
let contact_bypass = AppSettings::get_contact_bypass(cx);
let bypass_setting = AppSettings::get_contact_bypass(cx);
let task: Task<Result<BTreeSet<Room>, Error>> = cx.background_spawn(async move {
let task: Task<Result<HashSet<Room>, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let contacts = client.database().contacts_public_keys(public_key).await?;
// Get messages sent by the user
let send = Filter::new()
@@ -275,7 +289,7 @@ impl Registry {
let recv_events = client.database().query(recv).await?;
let events = send_events.merge(recv_events);
let mut rooms: BTreeSet<Room> = BTreeSet::new();
let mut rooms: HashSet<Room> = HashSet::new();
// Process each event and group by room hash
for event in events
@@ -287,17 +301,17 @@ impl Registry {
continue;
}
// Get all public keys from the event
let public_keys = event.all_pubkeys();
// Get all public keys from the event's tags
let mut public_keys = event.all_pubkeys();
public_keys.retain(|pk| pk != &public_key);
// Bypass screening flag
let mut bypass = false;
let mut bypassed = 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));
// If the user has enabled bypass screening in settings,
// check if any of the room's members are contacts of the current user
if bypass_setting {
bypassed = public_keys.iter().any(|k| contacts.contains(k));
}
// Check if the current user has sent at least one message to this room
@@ -312,7 +326,7 @@ impl Registry {
// Create a new room
let room = Room::new(&event).rearrange_by(public_key);
if is_ongoing || bypass {
if is_ongoing || bypassed {
rooms.insert(room.kind(RoomKind::Ongoing));
} else {
rooms.insert(room);
@@ -325,9 +339,11 @@ impl Registry {
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);
this.update_in(cx, move |_, window, cx| {
cx.defer_in(window, move |this, _window, cx| {
this.extend_rooms(rooms, cx);
this.sort(cx);
});
})
.ok();
}
@@ -339,24 +355,29 @@ impl Registry {
.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);
}
pub(crate) 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| {
*this = new_room;
cx.notify();
if new_room.created_at > this.created_at {
*this = new_room;
cx.notify();
}
});
} else {
let new_index = self.rooms.len();
room_map.insert(new_room.id, new_index);
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);
}
}
}
@@ -376,16 +397,18 @@ impl Registry {
weak_room
};
cx.emit(RegistrySignal::Open(weak_room));
cx.emit(RegistryEvent::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);
});
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);
});
}
}
}
}
@@ -396,7 +419,7 @@ impl Registry {
/// Updates room ordering based on the most recent messages.
pub fn event_to_message(
&mut self,
identity: PublicKey,
gift_wrap_id: EventId,
event: Event,
window: &mut Window,
cx: &mut Context<Self>,
@@ -404,10 +427,18 @@ impl Registry {
let id = event.uniq_id();
let author = event.pubkey;
let Some(identity) = self.identity else {
return;
};
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
let is_new_event = event.created_at > room.read(cx).created_at;
// Update room
room.update(cx, |this, cx| {
this.created_at(event.created_at, cx);
if is_new_event {
this.created_at(event.created_at, cx);
}
// Set this room is ongoing if the new message is from current user
if author == identity {
@@ -415,13 +446,17 @@ impl Registry {
}
// Emit the new message to the room
cx.defer_in(window, |this, window, cx| {
this.emit_message(event, window, cx);
cx.defer_in(window, move |this, _window, cx| {
this.emit_message(gift_wrap_id, event, cx);
});
});
// Re-sort the rooms registry by their created at
self.sort(cx);
// Resort all rooms in the registry by their created at (after updated)
if is_new_event {
cx.defer_in(window, |this, _window, cx| {
this.sort(cx);
});
}
} else {
let room = Room::new(&event)
.kind(RoomKind::default())
@@ -432,7 +467,7 @@ impl Registry {
// Notify the UI about the new room
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(RegistrySignal::NewRequest(RoomKind::default()));
cx.emit(RegistryEvent::NewRequest(RoomKind::default()));
});
}
}

View File

@@ -1,169 +1,170 @@
use std::cell::RefCell;
use std::iter::IntoIterator;
use std::rc::Rc;
use std::hash::Hash;
use chrono::{Local, TimeZone};
use gpui::SharedString;
use nostr_sdk::prelude::*;
use crate::room::SendError;
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum Message {
User(RenderedMessage),
Warning(String, Timestamp),
System(Timestamp),
}
/// 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, PartialEq, Eq)]
pub struct Message {
/// Unique identifier of the message (EventId from nostr_sdk)
impl Message {
pub fn user(user: impl Into<RenderedMessage>) -> Self {
Self::User(user.into())
}
pub fn warning(content: String) -> Self {
Self::Warning(content, Timestamp::now())
}
pub fn system() -> Self {
Self::System(Timestamp::default())
}
}
impl Ord for Message {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match (self, other) {
(Message::User(a), Message::User(b)) => a.cmp(b),
(Message::System(a), Message::System(b)) => a.cmp(b),
(Message::User(a), Message::System(b)) => a.created_at.cmp(b),
(Message::System(a), Message::User(b)) => a.cmp(&b.created_at),
(Message::Warning(_, a), Message::Warning(_, b)) => a.cmp(b),
(Message::Warning(_, a), Message::User(b)) => a.cmp(&b.created_at),
(Message::User(a), Message::Warning(_, b)) => a.created_at.cmp(b),
(Message::Warning(_, a), Message::System(b)) => a.cmp(b),
(Message::System(a), Message::Warning(_, b)) => a.cmp(b),
}
}
}
impl PartialOrd for Message {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Clone)]
pub struct RenderedMessage {
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 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 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>>,
/// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>,
}
/// 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 From<Event> for RenderedMessage {
fn from(inner: Event) -> Self {
let mentions = extract_mentions(&inner.content);
let replies_to = extract_reply_ids(&inner.tags);
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,
id: inner.id,
author: inner.pubkey,
content: inner.content,
created_at: inner.created_at,
mentions,
replies_to,
}
}
}
/// Sets the message content
pub fn content(mut self, content: impl Into<SharedString>) -> Self {
self.content = Some(content.into());
self
}
impl From<UnsignedEvent> for RenderedMessage {
fn from(inner: UnsignedEvent) -> Self {
let mentions = extract_mentions(&inner.content);
let replies_to = extract_reply_ids(&inner.tags);
/// 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 {
// Event ID must be known
id: inner.id.unwrap(),
author: inner.pubkey,
content: inner.content,
created_at: inner.created_at,
mentions,
replies_to,
}
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
impl From<Box<Event>> for RenderedMessage {
fn from(inner: Box<Event>) -> Self {
(*inner).into()
}
}
/// Builds the message wrapped in an Rc<RefCell<Message>>
pub fn build_rc(self) -> Result<Rc<RefCell<Message>>, String> {
self.build().map(|m| Rc::new(RefCell::new(m)))
impl From<&Box<Event>> for RenderedMessage {
fn from(inner: &Box<Event>) -> Self {
inner.to_owned().into()
}
}
/// 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 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);
}
}
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<_>>()
}
impl Message {
/// Creates a new MessageBuilder
pub fn builder(id: EventId, author: PublicKey) -> MessageBuilder {
MessageBuilder::new(id, author)
}
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
let mut replies_to = vec![];
/// Converts the message into an Rc<RefCell<Message>>
pub fn into_rc(self) -> Rc<RefCell<Self>> {
Rc::new(RefCell::new(self))
}
/// Builds a message from a builder and wraps it in Rc<RefCell>
pub fn build_rc(builder: MessageBuilder) -> Result<Rc<RefCell<Self>>, String> {
builder.build().map(|m| Rc::new(RefCell::new(m)))
}
/// 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")),
for tag in inner.filter(TagKind::e()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
.into()
}
for tag in inner.filter(TagKind::q()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
replies_to
}

View File

@@ -1,36 +1,80 @@
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::time::Duration;
use anyhow::{anyhow, Error};
use chrono::{Local, TimeZone};
use common::display::DisplayProfile;
use anyhow::Error;
use common::display::ReadableProfile;
use common::event::EventUtils;
use global::nostr_client;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
use global::constants::SEND_RETRY;
use global::{css, nostr_client};
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
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 struct SendReport {
pub receiver: PublicKey,
pub tags: Option<Vec<Tag>>,
pub status: Option<Output<EventId>>,
pub error: Option<SharedString>,
pub relays_not_found: bool,
}
impl SendReport {
pub fn new(receiver: PublicKey) -> Self {
Self {
receiver,
status: None,
error: None,
tags: None,
relays_not_found: false,
}
}
pub fn not_found(mut self) -> Self {
self.relays_not_found = true;
self
}
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
self.error = Some(error.into());
self.relays_not_found = false;
self
}
pub fn status(mut self, output: Output<EventId>) -> Self {
self.status = Some(output);
self.relays_not_found = false;
self
}
pub fn tags(mut self, tags: &Vec<Tag>) -> Self {
self.tags = Some(tags.to_owned());
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
}
}
}
#[derive(Debug, Clone)]
pub enum RoomSignal {
NewMessage(Message),
NewMessage((EventId, Box<Event>)),
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,
@@ -43,11 +87,11 @@ pub struct Room {
pub id: u64,
pub created_at: Timestamp,
/// Subject of the room
pub subject: Option<SharedString>,
pub subject: Option<String>,
/// Picture of the room
pub picture: Option<SharedString>,
pub picture: Option<String>,
/// All members of the room
pub members: SmallVec<[PublicKey; 2]>,
pub members: Vec<PublicKey>,
/// Kind
pub kind: RoomKind,
}
@@ -70,6 +114,12 @@ impl PartialEq for Room {
}
}
impl Hash for Room {
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
impl Eq for Room {}
impl EventEmitter<RoomSignal> for Room {}
@@ -78,21 +128,25 @@ 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 members from the event's tags and event's pubkey
let members = event
.all_pubkeys()
.into_iter()
.unique()
.sorted()
.collect_vec();
// 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())
tag.content().map(|s| s.to_owned())
} 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())
tag.content().map(|s| s.to_owned())
} else {
None
};
@@ -135,11 +189,9 @@ impl Room {
///
/// 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();
let (not_match, matches): (Vec<PublicKey>, Vec<PublicKey>) =
self.members.iter().partition(|&key| key != &rearrange_by);
self.members = not_match;
self.members.extend(matches);
self
}
@@ -182,8 +234,8 @@ impl Room {
///
/// * `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());
pub fn subject(&mut self, subject: String, cx: &mut Context<Self>) {
self.subject = Some(subject);
cx.notify();
}
@@ -193,42 +245,11 @@ impl Room {
///
/// * `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());
pub fn picture(&mut self, picture: String, cx: &mut Context<Self>) {
self.picture = Some(picture);
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.
@@ -240,8 +261,8 @@ impl Room {
///
/// # Returns
///
/// A SharedString containing the display name
pub fn display_name(&self, cx: &App) -> SharedString {
/// A string containing the display name
pub fn display_name(&self, cx: &App) -> String {
if let Some(subject) = self.subject.clone() {
subject
} else {
@@ -263,8 +284,8 @@ impl Room {
///
/// # Returns
///
/// A SharedString containing the image path or URL
pub fn display_image(&self, proxy: bool, cx: &App) -> SharedString {
/// A string containing the image path or URL
pub fn display_image(&self, proxy: bool, cx: &App) -> String {
if let Some(picture) = self.picture.as_ref() {
picture.clone()
} else if !self.is_group() {
@@ -283,7 +304,7 @@ impl Room {
}
/// Merge the names of the first two members of the room.
pub(crate) fn merge_name(&self, cx: &App) -> SharedString {
pub(crate) fn merge_name(&self, cx: &App) -> String {
let registry = Registry::read_global(cx);
if self.is_group() {
@@ -304,277 +325,131 @@ impl Room {
name = format!("{}, +{}", name, profiles.len() - 2);
}
name.into()
name
} 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();
/// Connects to all members' messaging relays
pub fn connect_relays(
&self,
cx: &App,
) -> Task<Result<HashMap<PublicKey, Vec<RelayUrl>>, Error>> {
let members = self.members.clone();
cx.background_spawn(async move {
let database = nostr_client().database();
let mut profiles = vec![];
let client = nostr_client();
let timeout = Duration::from_secs(3);
let mut processed = HashSet::new();
let mut relays: HashMap<PublicKey, Vec<RelayUrl>> = HashMap::new();
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));
}
if let Some((_, members)) = members.split_last() {
for member in members.iter() {
relays.insert(member.to_owned(), vec![]);
Ok(profiles)
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(member.to_owned())
.limit(1);
if let Ok(mut stream) = client.stream_events(filter, timeout).await {
if let Some(event) = stream.next().await {
if processed.insert(event.id) {
let urls = nip17::extract_owned_relay_list(event).collect_vec();
relays.entry(member.to_owned()).or_default().extend(urls);
}
}
}
}
};
Ok(relays)
})
}
/// 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());
pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<Event>, Error>> {
let members = self.members.clone();
cx.background_spawn(async move {
let mut messages = vec![];
let parser = NostrParser::new();
let database = nostr_client().database();
let client = nostr_client();
let public_key = members[members.len() - 1];
// 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<_>>();
let sent = Filter::new()
.kind(Kind::PrivateDirectMessage)
.author(public_key)
.pubkeys(members.clone());
for event in events.into_iter() {
let content = event.content.clone();
let tokens = parser.parse(&content);
let mut replies_to = vec![];
let recv = Filter::new()
.kind(Kind::PrivateDirectMessage)
.authors(members)
.pubkey(public_key);
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);
}
}
}
let sent_events = client.database().query(sent).await?;
let recv_events = client.database().query(recv).await?;
let events: Vec<Event> = sent_events.merge(recv_events).into_iter().collect();
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)
Ok(events)
})
}
/// 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.
/// The event must not been published to relays.
pub fn create_temp_message(
&self,
public_key: PublicKey,
receiver: PublicKey,
content: &str,
replies: Option<&Vec<Message>>,
) -> Option<Message> {
let builder = EventBuilder::private_msg_rumor(public_key, content);
replies: &[EventId],
) -> UnsignedEvent {
let builder = EventBuilder::private_msg_rumor(receiver, content);
let mut tags = vec![];
// 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]))
}
if replies.len() == 1 {
tags.push(Tag::event(replies[0]))
} else {
for id in replies.iter() {
tags.push(Tag::from_standardized(TagStandard::Quote {
event_id: id.to_owned(),
relay_url: None,
public_key: None,
}))
}
}
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
let mut event = builder.tags(tags).build(receiver);
// Ensure event ID is set
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()
event
}
/// 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
/// Create a task to sends a message to all members in the background
pub fn send_in_background(
&self,
content: &str,
replies: Option<&Vec<Message>>,
replies: Vec<EventId>,
backup: bool,
cx: &App,
) -> Task<Result<Vec<SendError>, Error>> {
) -> Task<Result<Vec<SendReport>, 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();
let mut public_keys = self.members.clone();
cx.background_spawn(async move {
let css = css();
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))
.filter_map(|&this| {
if this != public_key {
Some(Tag::public_key(this))
} else {
None
}
@@ -582,13 +457,15 @@ impl Room {
.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]))
}
if replies.len() == 1 {
tags.push(Tag::event(replies[0]))
} else {
for id in replies.iter() {
tags.push(Tag::from_standardized(TagStandard::Quote {
event_id: id.to_owned(),
relay_url: None,
public_key: None,
}))
}
}
@@ -604,67 +481,145 @@ impl Room {
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."));
};
// Remove the current public key from the list of receivers
public_keys.retain(|&pk| pk != public_key);
for receiver in receivers.iter() {
if let Err(e) = client
.send_private_msg(*receiver, &content, tags.clone())
// Stored all send errors
let mut reports = vec![];
for pubkey in public_keys.into_iter() {
match client
.send_private_msg(pubkey, &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(),
};
Ok(output) => {
let id = output.id().to_owned();
let auth_required = output.failed.iter().any(|m| m.1.starts_with("auth-"));
let report = SendReport::new(pubkey).status(output).tags(&tags);
reports.push(report);
if auth_required {
// Wait for authenticated and resent event successfully
for attempt in 0..=SEND_RETRY {
// Check if event was successfully resent
if let Some(output) = css
.resent_ids
.read()
.await
.iter()
.find(|e| e.id() == &id)
.cloned()
{
let output = SendReport::new(pubkey).status(output).tags(&tags);
reports.push(output);
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) => {
if let nostr_sdk::client::Error::PrivateMsgRelaysNotFound = e {
reports.push(SendReport::new(pubkey).not_found().tags(&tags));
} else {
reports.push(SendReport::new(pubkey).error(e.to_string()).tags(&tags));
}
}
}
}
// 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())
// Only send a backup message to current user if sent successfully to others
if reports.iter().all(|r| r.is_sent_success()) && backup {
match client
.send_private_msg(public_key, &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(output) => {
reports.push(SendReport::new(public_key).status(output).tags(&tags));
}
Err(e) => {
if let nostr_sdk::client::Error::PrivateMsgRelaysNotFound = e {
reports.push(SendReport::new(public_key).not_found());
} else {
reports
.push(SendReport::new(public_key).error(e.to_string()).tags(&tags));
}
}
}
}
Ok(reports)
})
}
}
pub(crate) fn extract_mentions(content: &str) -> Vec<PublicKey> {
let parser = NostrParser::new();
let tokens = parser.parse(content);
/// Create a task to resend a failed message
pub fn resend(
&self,
reports: Vec<SendReport>,
message: String,
backup: bool,
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
cx.background_spawn(async move {
let client = nostr_client();
let mut resend_reports = vec![];
let mut resend_tag = vec![];
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,
for report in reports.into_iter() {
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(report.receiver).status(resent));
}
if let Some(tags) = report.tags {
resend_tag.extend(tags);
}
}
}
}
// Only send a backup message to current user if sent successfully to others
if backup && !resend_reports.is_empty() {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let output = client
.send_private_msg(public_key, message, resend_tag)
.await?;
resend_reports.push(SendReport::new(public_key).status(output));
}
Ok(resend_reports)
})
.collect::<Vec<_>>()
}
/// Emits a new message signal to the current room
pub fn emit_message(&self, gift_wrap_id: EventId, event: Event, cx: &mut Context<Self>) {
cx.emit(RoomSignal::NewMessage((gift_wrap_id, Box::new(event))));
}
/// Emits a signal to refresh the current room's messages.
pub fn emit_refresh(&mut self, cx: &mut Context<Self>) {
cx.emit(RoomSignal::Refresh);
}
}

View File

@@ -1,5 +1,5 @@
use anyhow::anyhow;
use global::constants::SETTINGS_D;
use global::constants::SETTINGS_IDENTIFIER;
use global::nostr_client;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_sdk::prelude::*;
@@ -11,7 +11,7 @@ pub fn init(cx: &mut App) {
// Observe for state changes and save settings to database
state.update(cx, |this, cx| {
this.subscriptions
this._subscriptions
.push(cx.observe(&state, |this, _state, cx| {
this.set_settings(cx);
}));
@@ -49,6 +49,7 @@ setting_accessors! {
pub screening: bool,
pub contact_bypass: bool,
pub auto_login: bool,
pub auto_auth: bool,
}
#[derive(Serialize, Deserialize)]
@@ -60,6 +61,8 @@ pub struct Settings {
pub screening: bool,
pub contact_bypass: bool,
pub auto_login: bool,
pub auto_auth: bool,
pub authenticated_relays: Vec<RelayUrl>,
}
impl Default for Settings {
@@ -72,6 +75,8 @@ impl Default for Settings {
screening: true,
contact_bypass: true,
auto_login: false,
auto_auth: true,
authenticated_relays: vec![],
}
}
}
@@ -88,8 +93,7 @@ impl Global for GlobalAppSettings {}
pub struct AppSettings {
setting_values: Settings,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl AppSettings {
@@ -108,29 +112,26 @@ impl AppSettings {
cx.set_global(GlobalAppSettings(state));
}
fn new(cx: &mut Context<Self>) -> Self {
let setting_values = Settings::default();
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_new::<Self>(move |this, _window, cx| {
this.get_settings_from_db(cx);
}));
fn new(_cx: &mut Context<Self>) -> Self {
Self {
setting_values,
subscriptions,
setting_values: Settings::default(),
_subscriptions: smallvec![],
}
}
pub(crate) fn get_settings_from_db(&self, cx: &mut Context<Self>) {
pub fn load_settings(&self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, anyhow::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::ApplicationSpecificData)
.identifier(SETTINGS_D)
.identifier(SETTINGS_IDENTIFIER)
.author(public_key)
.limit(1);
if let Some(event) = nostr_client().database().query(filter).await?.first_owned() {
log::info!("Successfully loaded settings from database");
if let Some(event) = client.database().query(filter).await?.first_owned() {
Ok(serde_json::from_str(&event.content).unwrap_or(Settings::default()))
} else {
Err(anyhow!("Not found"))
@@ -149,22 +150,42 @@ impl AppSettings {
.detach();
}
pub(crate) fn set_settings(&self, cx: &mut Context<Self>) {
pub fn set_settings(&self, cx: &mut Context<Self>) {
if let Ok(content) = serde_json::to_string(&self.setting_values) {
cx.background_spawn(async move {
if let Ok(event) = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tags(vec![Tag::identifier(SETTINGS_D)])
let task: Task<Result<(), anyhow::Error>> = cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(SETTINGS_IDENTIFIER))
.build(public_key)
.sign(&Keys::generate())
.await
{
if let Err(e) = nostr_client().database().save_event(&event).await {
log::error!("Failed to save user settings: {e}");
} else {
log::info!("New settings have been saved successfully");
}
}
})
.detach();
.await?;
client.database().save_event(&event).await?;
Ok(())
});
task.detach();
}
}
pub fn is_auto_auth(&self) -> bool {
!self.setting_values.authenticated_relays.is_empty() && self.setting_values.auto_auth
}
pub fn is_authenticated(&self, url: &RelayUrl) -> bool {
self.setting_values.authenticated_relays.contains(url)
}
pub fn push_relay(&mut self, relay_url: &RelayUrl, cx: &mut Context<Self>) {
if !self.is_authenticated(relay_url) {
self.setting_values
.authenticated_relays
.push(relay_url.to_owned());
cx.notify();
}
}
}

View File

@@ -0,0 +1,25 @@
[package]
name = "signer_proxy"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
global = { path = "../global" }
nostr.workspace = true
smol.workspace = true
oneshot.workspace = true
anyhow.workspace = true
log.workspace = true
futures.workspace = true
smallvec.workspace = true
serde.workspace = true
serde_json.workspace = true
atomic-destructor = "0.3.0"
uuid = { version = "1.17", features = ["serde", "v4"] }
hyper = { version = "1.6", features = ["server", "http1"] }
hyper-util = { version = "0.1", features = ["server"] }
bytes = "1.10"
http-body-util = "0.1"

View File

@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<title>NIP-07 Proxy</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="container">
<h1>NIP-07 Proxy</h1>
<p>
This page acts as a proxy between your native application and
the NIP-07 browser extension.
</p>
<div class="status-box">
<strong>Status:</strong> <span id="status">Checking...</span>
</div>
<p>
<small
>Keep this tab open while using your application. The page
will automatically poll for requests from your native
app.</small
>
</p>
<h3>Debug Info</h3>
<p>
<small
>Check the browser console (F12) for detailed logs.</small
>
</p>
</div>
<script src="proxy.js"></script>
</body>
</html>

View File

@@ -0,0 +1,151 @@
let isPolling = false;
async function pollForRequests() {
if (isPolling) return;
isPolling = true;
try {
const response = await fetch("/api/pending");
const data = await response.json();
console.log("Polled for requests, got:", data);
// Process any new requests
if (data.requests && data.requests.length > 0) {
console.log(`Processing ${data.requests.length} requests`);
for (const request of data.requests) {
await handleNip07Request(request);
}
}
} catch (error) {
console.error("Polling error:", error);
updateStatus("Error: " + error.message, "error");
}
isPolling = false;
}
async function handleNip07Request(request) {
console.log("Handling request:", request);
try {
let result;
if (!window.nostr) {
throw new Error("NIP-07 extension not available");
}
switch (request.method) {
case "get_public_key":
console.log("Calling nostr.getPublicKey()");
result = await window.nostr.getPublicKey();
console.log("Got public key:", result);
break;
case "sign_event":
console.log("Calling nostr.signEvent() with:", request.params);
result = await window.nostr.signEvent(request.params);
console.log("Got signed event:", result);
break;
case "nip04_encrypt":
console.log("Calling nostr.nip04.encrypt()");
result = await window.nostr.nip04.encrypt(
request.params.public_key,
request.params.content,
);
break;
case "nip04_decrypt":
console.log("Calling nostr.nip04.decrypt()");
result = await window.nostr.nip04.decrypt(
request.params.public_key,
request.params.content,
);
break;
case "nip44_encrypt":
console.log("Calling nostr.nip44.encrypt()");
result = await window.nostr.nip44.encrypt(
request.params.public_key,
request.params.content,
);
break;
case "nip44_decrypt":
console.log("Calling nostr.nip44.decrypt()");
result = await window.nostr.nip44.decrypt(
request.params.public_key,
request.params.content,
);
break;
default:
throw new Error(`Unknown method: ${request.method}`);
}
// Send response back to server
const responsePayload = {
id: request.id,
result: result,
error: null,
};
console.log("Sending response:", responsePayload);
await fetch("/api/response", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(responsePayload),
});
console.log("Response sent successfully");
updateStatus("Request processed successfully", "connected");
} catch (error) {
console.error("Error handling request:", error);
// Send error response back to server
const errorPayload = {
id: request.id,
result: null,
error: error.message,
};
console.log("Sending error response:", errorPayload);
await fetch("/api/response", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(errorPayload),
});
updateStatus("Error: " + error.message, "error");
}
}
function updateStatus(message, className) {
const statusEl = document.getElementById("status");
statusEl.textContent = message;
statusEl.className = className;
}
// Start polling when page loads
window.addEventListener("load", () => {
console.log("NIP-07 Proxy loaded");
// Check if NIP-07 extension is available
if (window.nostr) {
console.log("NIP-07 extension detected");
updateStatus("Connected to NIP-07 extension - Ready", "connected");
} else {
console.log("NIP-07 extension not found");
updateStatus("NIP-07 extension not found", "error");
}
// Start polling every 500 ms
setInterval(pollForRequests, 500);
});

View File

@@ -0,0 +1,73 @@
use std::{fmt, io};
use hyper::http;
use nostr::event;
use oneshot::RecvError;
/// Error
#[derive(Debug)]
pub enum Error {
/// I/O error
Io(io::Error),
/// HTTP error
Http(http::Error),
/// Json error
Json(serde_json::Error),
/// Event error
Event(event::Error),
/// Oneshot channel receive error
OneShotRecv(RecvError),
/// Generic error
Generic(String),
/// Timeout
Timeout,
/// The server is shutdown
Shutdown,
}
impl std::error::Error for Error {}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(e) => write!(f, "{e}"),
Self::Http(e) => write!(f, "{e}"),
Self::Json(e) => write!(f, "{e}"),
Self::Event(e) => write!(f, "{e}"),
Self::OneShotRecv(e) => write!(f, "{e}"),
Self::Generic(e) => write!(f, "{e}"),
Self::Timeout => write!(f, "timeout"),
Self::Shutdown => write!(f, "server is shutdown"),
}
}
}
impl From<io::Error> for Error {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
impl From<http::Error> for Error {
fn from(e: http::Error) -> Self {
Self::Http(e)
}
}
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Self {
Self::Json(e)
}
}
impl From<event::Error> for Error {
fn from(e: event::Error) -> Self {
Self::Event(e)
}
}
impl From<RecvError> for Error {
fn from(e: RecvError) -> Self {
Self::OneShotRecv(e)
}
}

View File

@@ -0,0 +1,678 @@
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener};
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
use bytes::Bytes;
use futures::FutureExt;
use http_body_util::combinators::BoxBody;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use nostr::prelude::{BoxedFuture, SignerBackend};
use nostr::{Event, NostrSigner, PublicKey, SignerError, UnsignedEvent};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize, Serializer};
use serde_json::{json, Value};
use smol::io::{AsyncRead, AsyncWrite};
use smol::lock::Mutex;
use uuid::Uuid;
use crate::error::Error;
mod error;
const HTML: &str = include_str!("../index.html");
const JS: &str = include_str!("../proxy.js");
const CSS: &str = include_str!("../style.css");
/// Wrapper to make smol::Async<TcpStream> compatible with hyper
struct HyperIo<T> {
inner: T,
}
impl<T> HyperIo<T> {
fn new(inner: T) -> Self {
Self { inner }
}
}
impl<T: AsyncRead + Unpin> hyper::rt::Read for HyperIo<T> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
mut buf: hyper::rt::ReadBufCursor<'_>,
) -> Poll<Result<(), std::io::Error>> {
let mut tbuf = vec![0; buf.remaining()];
match Pin::new(&mut self.inner).poll_read(cx, &mut tbuf) {
Poll::Ready(Ok(n)) => {
buf.put_slice(&tbuf[..n]);
Poll::Ready(Ok(()))
}
Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
Poll::Pending => Poll::Pending,
}
}
}
impl<T: AsyncWrite + Unpin> hyper::rt::Write for HyperIo<T> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, std::io::Error>> {
Pin::new(&mut self.inner).poll_write(cx, buf)
}
fn poll_flush(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<(), std::io::Error>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<(), std::io::Error>> {
Pin::new(&mut self.inner).poll_close(cx)
}
}
type PendingResponseMap = HashMap<Uuid, oneshot::Sender<Result<Value, String>>>;
#[derive(Debug, Deserialize)]
struct Message {
id: Uuid,
error: Option<String>,
result: Option<Value>,
}
impl Message {
fn into_result(self) -> Result<Value, String> {
if let Some(error) = self.error {
Err(error)
} else {
Ok(self.result.unwrap_or(Value::Null))
}
}
}
#[derive(Debug, Clone, Copy)]
enum RequestMethod {
GetPublicKey,
SignEvent,
Nip04Encrypt,
Nip04Decrypt,
Nip44Encrypt,
Nip44Decrypt,
}
impl RequestMethod {
fn as_str(&self) -> &str {
match self {
Self::GetPublicKey => "get_public_key",
Self::SignEvent => "sign_event",
Self::Nip04Encrypt => "nip04_encrypt",
Self::Nip04Decrypt => "nip04_decrypt",
Self::Nip44Encrypt => "nip44_encrypt",
Self::Nip44Decrypt => "nip44_decrypt",
}
}
}
impl Serialize for RequestMethod {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize)]
struct RequestData {
id: Uuid,
method: RequestMethod,
params: Value,
}
impl RequestData {
#[inline]
fn new(method: RequestMethod, params: Value) -> Self {
Self {
id: Uuid::new_v4(),
method,
params,
}
}
}
#[derive(Serialize)]
struct Requests<'a> {
requests: &'a [RequestData],
}
impl<'a> Requests<'a> {
#[inline]
fn new(requests: &'a [RequestData]) -> Self {
Self { requests }
}
#[inline]
fn len(&self) -> usize {
self.requests.len()
}
}
/// Params for NIP-04 and NIP-44 encryption/decryption
#[derive(Serialize)]
struct CryptoParams<'a> {
public_key: &'a PublicKey,
content: &'a str,
}
impl<'a> CryptoParams<'a> {
#[inline]
fn new(public_key: &'a PublicKey, content: &'a str) -> Self {
Self {
public_key,
content,
}
}
}
#[derive(Debug)]
struct ProxyState {
/// Requests waiting to be picked up by browser
pub outgoing_requests: Mutex<Vec<RequestData>>,
/// Map of request ID to response sender
pub pending_responses: Mutex<PendingResponseMap>,
/// Last time the client ask for the pending requests
pub last_pending_request: Arc<AtomicU64>,
/// Notification for shutdown
pub shutdown_notify: smol::channel::Receiver<()>,
pub shutdown_sender: smol::channel::Sender<()>,
}
/// Configuration options for [`BrowserSignerProxy`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserSignerProxyOptions {
/// Request timeout for the signer extension. Default is 30 seconds.
pub timeout: Duration,
/// Proxy server IP address and port. Default is `127.0.0.1:7400`.
pub addr: SocketAddr,
}
#[derive(Debug, Clone)]
struct InnerBrowserSignerProxy {
/// Configuration options for the proxy
options: BrowserSignerProxyOptions,
/// Internal state of the proxy including request queues
state: Arc<ProxyState>,
/// Flag to indicate if the server is shutdown
is_shutdown: Arc<AtomicBool>,
/// Flat indicating if the server is started
is_started: Arc<AtomicBool>,
}
impl AtomicDestroyer for InnerBrowserSignerProxy {
fn on_destroy(&self) {
self.shutdown();
}
}
impl InnerBrowserSignerProxy {
#[inline]
fn is_shutdown(&self) -> bool {
self.is_shutdown.load(Ordering::SeqCst)
}
fn shutdown(&self) {
// Mark the server as shutdown
self.is_shutdown.store(true, Ordering::SeqCst);
// Notify all waiters that the proxy is shutting down
let _ = self.state.shutdown_sender.try_send(());
}
}
/// Nostr Browser Signer Proxy
///
/// Proxy to use Nostr Browser signer (NIP-07) in native applications.
#[derive(Debug, Clone)]
pub struct BrowserSignerProxy {
inner: AtomicDestructor<InnerBrowserSignerProxy>,
}
impl Default for BrowserSignerProxyOptions {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
// 7 for NIP-07 and 400 because the NIP title is 40 bytes :)
addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)),
}
}
}
impl BrowserSignerProxyOptions {
/// Sets the timeout duration.
pub const fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Sets the IP address.
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
self.addr = SocketAddr::new(new_ip, self.addr.port());
self
}
/// Sets the port number.
pub const fn port(mut self, new_port: u16) -> Self {
self.addr = SocketAddr::new(self.addr.ip(), new_port);
self
}
}
impl BrowserSignerProxy {
/// Construct a new browser signer proxy
pub fn new(options: BrowserSignerProxyOptions) -> Self {
let (shutdown_sender, shutdown_notify) = smol::channel::unbounded();
let state = ProxyState {
outgoing_requests: Mutex::new(Vec::new()),
pending_responses: Mutex::new(HashMap::new()),
last_pending_request: Arc::new(AtomicU64::new(0)),
shutdown_notify,
shutdown_sender,
};
Self {
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
options,
state: Arc::new(state),
is_shutdown: Arc::new(AtomicBool::new(false)),
is_started: Arc::new(AtomicBool::new(false)),
}),
}
}
/// Indicates whether the server is currently running.
#[inline]
pub fn is_started(&self) -> bool {
self.inner.is_started.load(Ordering::SeqCst)
}
/// Checks if there is an open browser tap ready to respond to requests by
/// verifying the time since the last pending request.
#[inline]
pub fn is_session_active(&self) -> bool {
current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2
}
/// Get the signer proxy webpage URL
#[inline]
pub fn url(&self) -> String {
format!("http://{}", self.inner.options.addr)
}
/// Start the proxy
///
/// If this is not called, will be automatically started on the first interaction with the signer.
pub async fn start(&self) -> Result<(), Error> {
// Ensure is not shutdown
if self.inner.is_shutdown() {
return Err(Error::Shutdown);
}
// Mark the proxy as started and check if was already started
let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst);
// Immediately return if already started
if is_started {
return Ok(());
}
let listener = match smol::Async::<TcpListener>::bind(self.inner.options.addr) {
Ok(listener) => listener,
Err(e) => {
// Undo the started flag if binding fails
self.inner.is_started.store(false, Ordering::SeqCst);
// Propagate error
return Err(Error::from(e));
}
};
let addr: SocketAddr = self.inner.options.addr;
let state: Arc<ProxyState> = self.inner.state.clone();
smol::spawn(async move {
log::info!("Starting proxy server on {addr}");
loop {
futures::select! {
accept_result = listener.accept().fuse() => {
let (stream, _) = match accept_result {
Ok(conn) => conn,
Err(e) => {
log::error!("Failed to accept connection: {e}");
continue;
}
};
let io = HyperIo::new(stream);
let state: Arc<ProxyState> = state.clone();
let shutdown_notify = state.shutdown_notify.clone();
smol::spawn(async move {
let service = service_fn(move |req| {
handle_request(req, state.clone())
});
futures::select! {
res = http1::Builder::new().serve_connection(io, service).fuse() => {
if let Err(e) = res {
log::error!("Error serving connection: {e}");
}
}
_ = shutdown_notify.recv().fuse() => {
log::debug!("Closing connection, proxy server is shutting down.");
}
}
}).detach();
},
_ = state.shutdown_notify.recv().fuse() => {
break;
}
}
}
log::info!("Shutting down proxy server.");
}).detach();
Ok(())
}
#[inline]
async fn store_pending_response(&self, id: Uuid, tx: oneshot::Sender<Result<Value, String>>) {
let mut pending_responses = self.inner.state.pending_responses.lock().await;
pending_responses.insert(id, tx);
}
#[inline]
async fn store_outgoing_request(&self, request: RequestData) {
let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await;
outgoing_requests.push(request);
}
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
where
T: DeserializeOwned,
{
// Start the proxy if not already started
self.start().await?;
// Construct the request
let request: RequestData = RequestData::new(method, params);
// Create a oneshot channel
let (tx, rx) = oneshot::channel();
// Store the response sender
self.store_pending_response(request.id, tx).await;
// Add to outgoing requests queue
self.store_outgoing_request(request).await;
// Wait for response
let timeout_fut = smol::Timer::after(self.inner.options.timeout);
let recv_fut = rx;
match futures::future::select(timeout_fut, recv_fut).await {
futures::future::Either::Left(_) => Err(Error::Timeout),
futures::future::Either::Right((recv_result, _)) => {
match recv_result.map_err(|_| Error::Generic("Channel closed".to_string()))? {
Ok(res) => Ok(serde_json::from_value(res)?),
Err(error) => Err(Error::Generic(error)),
}
}
}
}
#[inline]
async fn _get_public_key(&self) -> Result<PublicKey, Error> {
self.request(RequestMethod::GetPublicKey, json!({})).await
}
#[inline]
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
let event: Event = self
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
.await?;
event.verify()?;
Ok(event)
}
#[inline]
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?)
.await
}
}
impl NostrSigner for BrowserSignerProxy {
fn backend(&self) -> SignerBackend {
SignerBackend::BrowserExtension
}
#[inline]
fn get_public_key(&self) -> BoxedFuture<Result<PublicKey, SignerError>> {
Box::pin(async move { self._get_public_key().await.map_err(SignerError::backend) })
}
#[inline]
fn sign_event(&self, unsigned: UnsignedEvent) -> BoxedFuture<Result<Event, SignerError>> {
Box::pin(async move {
self._sign_event(unsigned)
.await
.map_err(SignerError::backend)
})
}
#[inline]
fn nip04_encrypt<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move {
self._nip04_encrypt(public_key, content)
.await
.map_err(SignerError::backend)
})
}
#[inline]
fn nip04_decrypt<'a>(
&'a self,
public_key: &'a PublicKey,
encrypted_content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move {
self._nip04_decrypt(public_key, encrypted_content)
.await
.map_err(SignerError::backend)
})
}
#[inline]
fn nip44_encrypt<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move {
self._nip44_encrypt(public_key, content)
.await
.map_err(SignerError::backend)
})
}
#[inline]
fn nip44_decrypt<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move {
self._nip44_decrypt(public_key, payload)
.await
.map_err(SignerError::backend)
})
}
}
async fn handle_request(
req: Request<Incoming>,
state: Arc<ProxyState>,
) -> Result<Response<BoxBody<Bytes, Error>>, Error> {
match (req.method(), req.uri().path()) {
// Serve the HTML proxy page
(&Method::GET, "/") => Ok(Response::builder()
.header("Content-Type", "text/html")
.body(full(HTML))?),
// Serve the CSS page style
(&Method::GET, "/style.css") => Ok(Response::builder()
.header("Content-Type", "text/css")
.body(full(CSS))?),
// Serve the JS proxy script
(&Method::GET, "/proxy.js") => Ok(Response::builder()
.header("Content-Type", "application/javascript")
.body(full(JS))?),
// Browser polls this endpoint to get pending requests
(&Method::GET, "/api/pending") => {
state
.last_pending_request
.store(current_time(), Ordering::SeqCst);
let mut outgoing = state.outgoing_requests.lock().await;
let requests: Requests<'_> = Requests::new(&outgoing);
let json: String = serde_json::to_string(&requests)?;
log::debug!("Sending {} pending requests to browser", requests.len());
// Clear the outgoing requests after sending them
outgoing.clear();
Ok(Response::builder()
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", "*")
.body(full(json))?)
}
// Get response
(&Method::POST, "/api/response") => {
// Correctly collect the body bytes from the stream
let body_bytes: Bytes = match req.into_body().collect().await {
Ok(collected) => collected.to_bytes(),
Err(e) => {
log::error!("Failed to read body: {e}");
let response = Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(full("Failed to read body"))?;
return Ok(response);
}
};
// Handle responses from the browser extension
let message: Message = match serde_json::from_slice(&body_bytes) {
Ok(json) => json,
Err(_) => {
let response = Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(full("Invalid JSON"))?;
return Ok(response);
}
};
log::debug!("Received response from browser: {message:?}");
let id: Uuid = message.id;
let mut pending = state.pending_responses.lock().await;
match pending.remove(&id) {
Some(sender) => {
let _ = sender.send(message.into_result());
}
None => log::warn!("No pending request found for {id}"),
}
let response = Response::builder()
.header("Access-Control-Allow-Origin", "*")
.body(full("OK"))?;
Ok(response)
}
(&Method::OPTIONS, _) => {
// Handle CORS preflight requests
let response = Response::builder()
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type")
.body(full(""))?;
Ok(response)
}
// 404 - not found
_ => {
let response = Response::builder()
.status(StatusCode::NOT_FOUND)
.body(full("Not Found"))?;
Ok(response)
}
}
}
#[inline]
fn full<T: Into<Bytes>>(chunk: T) -> BoxBody<Bytes, Error> {
Full::new(chunk.into())
.map_err(|never| match never {})
.boxed()
}
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
/// time is before the epoch, returns 0.
#[inline]
fn current_time() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
}

View File

@@ -0,0 +1,30 @@
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans,
Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
padding: 20px;
background-color: #f0f0f0;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.connected {
color: green;
font-weight: bold;
}
.error {
color: red;
font-weight: bold;
}
.status-box {
background: #f9f9f9;
padding: 10px;
border-radius: 4px;
margin: 10px 0;
border-left: 4px solid #ccc;
}

View File

@@ -118,14 +118,14 @@ impl Render for TitleBar {
.w_full()
.when(cx.theme().platform_kind.is_mac(), |this| {
this.on_click(|event, window, _| {
if event.up.click_count == 2 {
if event.click_count() == 2 {
window.titlebar_double_click();
}
})
})
.when(cx.theme().platform_kind.is_linux(), |this| {
this.on_click(|event, window, _| {
if event.up.click_count == 2 {
if event.click_count() == 2 {
window.zoom_window();
}
})

View File

@@ -3,4 +3,4 @@
///
/// Magic number: There is one extra pixel of padding on the left side due to
/// the 1px border around the window on macOS apps.
pub const TRAFFIC_LIGHT_PADDING: f32 = 71.;
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;

View File

@@ -1,8 +1,8 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, relative, AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement,
IntoElement, MouseButton, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement as _, Styled, Window,
IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, Stateful,
StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
};
use theme::ActiveTheme;
@@ -10,11 +10,6 @@ use crate::indicator::Indicator;
use crate::tooltip::Tooltip;
use crate::{h_flex, Disableable, Icon, Selectable, Sizable, Size, StyledExt};
pub enum ButtonRounded {
Normal,
Full,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct ButtonCustomVariant {
color: Hsla,
@@ -121,8 +116,8 @@ type OnClick = Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>
/// A Button element.
#[derive(IntoElement)]
pub struct Button {
pub base: Div,
id: ElementId,
base: Stateful<Div>,
style: StyleRefinement,
icon: Option<Icon>,
label: Option<SharedString>,
@@ -130,7 +125,7 @@ pub struct Button {
children: Vec<AnyElement>,
variant: ButtonVariant,
rounded: ButtonRounded,
rounded: bool,
size: Size,
disabled: bool,
@@ -156,14 +151,14 @@ impl From<Button> for AnyElement {
impl Button {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div().flex_shrink_0(),
id: id.into(),
base: div().id(id.into()).flex_shrink_0(),
style: StyleRefinement::default(),
icon: None,
label: None,
disabled: false,
selected: false,
variant: ButtonVariant::default(),
rounded: ButtonRounded::Normal,
rounded: false,
size: Size::Medium,
tooltip: None,
on_click: None,
@@ -177,9 +172,9 @@ impl Button {
}
}
/// Set the border radius of the Button.
pub fn rounded(mut self, rounded: impl Into<ButtonRounded>) -> Self {
self.rounded = rounded.into();
/// Make the button rounded.
pub fn rounded(mut self) -> Self {
self.rounded = true;
self
}
@@ -255,14 +250,14 @@ impl Disableable for Button {
}
impl Selectable for Button {
fn element_id(&self) -> &ElementId {
&self.id
}
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl Sizable for Button {
@@ -280,8 +275,8 @@ impl ButtonVariants for Button {
}
impl Styled for Button {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
@@ -308,15 +303,15 @@ impl RenderOnce for Button {
};
self.base
.id(self.id)
.flex_shrink_0()
.flex()
.items_center()
.justify_center()
.cursor_pointer()
.cursor_default()
.overflow_hidden()
.map(|this| match self.rounded {
ButtonRounded::Normal => this.rounded(cx.theme().radius),
ButtonRounded::Full => this.rounded_full(),
false => this.rounded(cx.theme().radius),
true => this.rounded_full(),
})
.map(|this| {
if self.label.is_none() && self.children.is_empty() {
@@ -359,6 +354,8 @@ impl RenderOnce for Button {
Size::XSmall => {
if self.icon.is_some() {
this.h_6().pl_2().pr_2p5()
} else if self.cta {
this.h_6().px_4()
} else {
this.h_6().px_2()
}
@@ -366,6 +363,8 @@ impl RenderOnce for Button {
Size::Small => {
if self.icon.is_some() {
this.h_7().pl_2().pr_2p5()
} else if self.cta {
this.h_7().px_4()
} else {
this.h_7().px_2()
}
@@ -388,10 +387,6 @@ impl RenderOnce for Button {
}
})
.text_color(normal_style.fg)
.when(self.selected, |this| {
let selected_style = style.selected(window, cx);
this.bg(selected_style.bg).text_color(selected_style.fg)
})
.when(!self.disabled && !self.selected, |this| {
this.bg(normal_style.bg)
.hover(|this| {
@@ -403,6 +398,10 @@ impl RenderOnce for Button {
this.bg(active_style.bg).text_color(active_style.fg)
})
})
.when(self.selected, |this| {
let selected_style = style.selected(window, cx);
this.bg(selected_style.bg).text_color(selected_style.fg)
})
.when(self.disabled, |this| {
let disabled_style = style.disabled(window, cx);
this.cursor_not_allowed()
@@ -410,6 +409,7 @@ impl RenderOnce for Button {
.text_color(disabled_style.fg)
.shadow_none()
})
.refine_style(&self.style)
.child({
h_flex()
.id("label")

View File

@@ -54,13 +54,13 @@ impl Disableable for Checkbox {
}
impl Selectable for Checkbox {
fn element_id(&self) -> &ElementId {
&self.id
}
fn selected(self, selected: bool) -> Self {
self.checked(selected)
}
fn is_selected(&self) -> bool {
self.checked
}
}
impl RenderOnce for Checkbox {

View File

@@ -412,16 +412,15 @@ impl TabPanel {
let is_zoomed = self.is_zoomed && state.zoomable;
let view = cx.entity().clone();
let build_popup_menu = move |this, cx: &App| view.read(cx).popup_menu(this, cx);
let toolbar = self.toolbar_buttons(window, cx);
let has_toolbar = !toolbar.is_empty();
h_flex()
.p_0p5()
.gap_1()
.occlude()
.items_center()
.children(
self.toolbar_buttons(window, cx)
.into_iter()
.map(|btn| btn.small().ghost()),
)
.rounded_full()
.children(toolbar.into_iter().map(|btn| btn.small().ghost().rounded()))
.when(self.is_zoomed, |this| {
this.child(
Button::new("zoom")
@@ -434,11 +433,16 @@ impl TabPanel {
})),
)
})
.when(has_toolbar, |this| {
this.bg(cx.theme().surface_background)
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
})
.child(
Button::new("menu")
.icon(IconName::Ellipsis)
.small()
.ghost()
.rounded()
.popup_menu({
let zoomable = state.zoomable;
let closable = state.closable;
@@ -647,7 +651,7 @@ impl TabPanel {
.child(
div()
.size_full()
.rounded_lg()
.rounded_xl()
.shadow_sm()
.when(cx.theme().mode.is_dark(), |this| this.shadow_lg())
.bg(cx.theme().panel_background)
@@ -667,7 +671,7 @@ impl TabPanel {
.p_1()
.child(
div()
.rounded_lg()
.rounded_xl()
.border_1()
.border_color(cx.theme().element_disabled)
.bg(cx.theme().drop_target_background)

View File

@@ -223,7 +223,7 @@ where
.justify_center()
.py_6()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::Inbox).size(px(28.)))
.child(Icon::new(IconName::Loader).size(px(28.)))
.into_any_element()
}
}

View File

@@ -10,7 +10,7 @@ pub trait InteractiveElementExt: InteractiveElement {
Self: Sized,
{
self.interactivity().on_click(move |event, window, cx| {
if event.up.click_count == 2 {
if event.click_count() == 2 {
listener(event, window, cx);
}
});

View File

@@ -9,14 +9,11 @@ use crate::{Sizable, Size};
#[derive(IntoElement, Clone)]
pub enum IconName {
AddressBook,
ArrowIn,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowUpCircle,
Bell,
CaretUp,
CaretDown,
CaretDownFill,
@@ -28,18 +25,12 @@ pub enum IconName {
CloseCircle,
CloseCircleFill,
Copy,
EditFill,
Edit,
Ellipsis,
Eye,
EyeOff,
EmojiFill,
Folder,
FolderFill,
Filter,
FilterFill,
Inbox,
Info,
Language,
Loader,
Logout,
Moon,
@@ -54,24 +45,22 @@ pub enum IconName {
Plus,
PlusFill,
PlusCircleFill,
Relays,
ResizeCorner,
Reply,
Report,
Forward,
Refresh,
Signal,
Search,
SearchFill,
Settings,
Server,
SortAscending,
SortDescending,
Sun,
Toggle,
ToggleFill,
ThumbsDown,
ThumbsUp,
Upload,
UsersThreeFill,
OpenUrl,
Warning,
WindowClose,
WindowMaximize,
WindowMinimize,
@@ -81,14 +70,11 @@ pub enum IconName {
impl IconName {
pub fn path(self) -> SharedString {
match self {
Self::AddressBook => "icons/address-book.svg",
Self::ArrowIn => "icons/arrows-in.svg",
Self::ArrowDown => "icons/arrow-down.svg",
Self::ArrowLeft => "icons/arrow-left.svg",
Self::ArrowRight => "icons/arrow-right.svg",
Self::ArrowUp => "icons/arrow-up.svg",
Self::ArrowUpCircle => "icons/arrow-up-circle.svg",
Self::Bell => "icons/bell.svg",
Self::CaretRight => "icons/caret-right.svg",
Self::CaretUp => "icons/caret-up.svg",
Self::CaretDown => "icons/caret-down.svg",
@@ -100,18 +86,12 @@ impl IconName {
Self::CloseCircle => "icons/close-circle.svg",
Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Copy => "icons/copy.svg",
Self::EditFill => "icons/edit-fill.svg",
Self::Edit => "icons/edit.svg",
Self::Ellipsis => "icons/ellipsis.svg",
Self::Eye => "icons/eye.svg",
Self::EmojiFill => "icons/emoji-fill.svg",
Self::EyeOff => "icons/eye-off.svg",
Self::Folder => "icons/folder.svg",
Self::FolderFill => "icons/folder-fill.svg",
Self::Filter => "icons/filter.svg",
Self::FilterFill => "icons/filter-fill.svg",
Self::Inbox => "icons/inbox.svg",
Self::Info => "icons/info.svg",
Self::Language => "icons/language.svg",
Self::Loader => "icons/loader.svg",
Self::Logout => "icons/logout.svg",
Self::Moon => "icons/moon.svg",
@@ -126,24 +106,22 @@ impl IconName {
Self::Plus => "icons/plus.svg",
Self::PlusFill => "icons/plus-fill.svg",
Self::PlusCircleFill => "icons/plus-circle-fill.svg",
Self::Relays => "icons/relays.svg",
Self::ResizeCorner => "icons/resize-corner.svg",
Self::Reply => "icons/reply.svg",
Self::Report => "icons/report.svg",
Self::Forward => "icons/forward.svg",
Self::Refresh => "icons/refresh.svg",
Self::Signal => "icons/signal.svg",
Self::Search => "icons/search.svg",
Self::SearchFill => "icons/search-fill.svg",
Self::Settings => "icons/settings.svg",
Self::Server => "icons/server.svg",
Self::SortAscending => "icons/sort-ascending.svg",
Self::SortDescending => "icons/sort-descending.svg",
Self::Sun => "icons/sun.svg",
Self::Toggle => "icons/toggle.svg",
Self::ToggleFill => "icons/toggle-fill.svg",
Self::ThumbsDown => "icons/thumbs-down.svg",
Self::ThumbsUp => "icons/thumbs-up.svg",
Self::Upload => "icons/upload.svg",
Self::UsersThreeFill => "icons/users-three-fill.svg",
Self::OpenUrl => "icons/open-url.svg",
Self::Warning => "icons/warning.svg",
Self::WindowClose => "icons/window-close.svg",
Self::WindowMaximize => "icons/window-maximize.svg",
Self::WindowMinimize => "icons/window-minimize.svg",

View File

@@ -20,7 +20,7 @@ impl Indicator {
pub fn new() -> Self {
Self {
size: Size::Small,
speed: Duration::from_secs_f64(0.8),
speed: Duration::from_secs(1),
icon: Icon::new(IconName::Loader),
color: None,
}
@@ -52,17 +52,15 @@ impl Sizable for Indicator {
impl RenderOnce for Indicator {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
div()
.child(
self.icon
.with_size(self.size)
.when_some(self.color, |this, color| this.text_color(color))
.with_animation(
"circle",
Animation::new(self.speed).repeat().with_easing(ease_in_out),
|this, delta| this.transform(Transformation::rotate(percentage(delta))),
),
)
.into_element()
div().child(
self.icon
.with_size(self.size)
.when_some(self.color, |this, color| this.text_color(color))
.with_animation(
"circle",
Animation::new(self.speed).repeat().with_easing(ease_in_out),
|this, delta| this.transform(Transformation::rotate(percentage(delta))),
),
)
}
}

View File

@@ -1077,6 +1077,7 @@ impl InputState {
}
let offset = self.next_boundary(self.cursor_offset());
// ignore if offset is "\n"
if self
.text_for_range(
@@ -1251,6 +1252,7 @@ impl InputState {
}
self.selecting = true;
let offset = self.index_for_mouse_position(event.position, window, cx);
// Double click to select word
@@ -1259,6 +1261,12 @@ impl InputState {
return;
}
// Triple click to select line
if event.button == MouseButton::Left && event.click_count == 3 {
self.select_line(window, cx);
return;
}
if event.modifiers.shift {
self.select_to(offset, window, cx);
} else {
@@ -1414,7 +1422,7 @@ impl InputState {
/// The offset is the UTF-8 offset.
///
/// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset.
fn move_to(&mut self, offset: usize, _: &mut Window, cx: &mut Context<Self>) {
fn move_to(&mut self, offset: usize, _window: &mut Window, cx: &mut Context<Self>) {
let offset = offset.clamp(0, self.text.len());
self.selected_range = offset..offset;
self.pause_blink_cursor(cx);
@@ -1541,8 +1549,9 @@ impl InputState {
/// The offset is the UTF-8 offset.
///
/// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset.
fn select_to(&mut self, offset: usize, _: &mut Window, cx: &mut Context<Self>) {
fn select_to(&mut self, offset: usize, _window: &mut Window, cx: &mut Context<Self>) {
let offset = offset.clamp(0, self.text.len());
if self.selection_reversed {
self.selected_range.start = offset
} else {
@@ -1563,9 +1572,11 @@ impl InputState {
self.selected_range.end = word_range.end;
}
}
if self.selected_range.is_empty() {
self.update_preferred_x_offset(cx);
}
cx.notify()
}
@@ -1579,9 +1590,11 @@ impl InputState {
let mut start = self.offset_to_utf16(offset);
let mut end = start;
let prev_text = self
.text_for_range(self.range_to_utf16(&(0..start)), &mut None, window, cx)
.unwrap_or_default();
let next_text = self
.text_for_range(
self.range_to_utf16(&(end..self.text.len())),
@@ -1619,9 +1632,18 @@ impl InputState {
self.selected_range = start..end;
self.selected_word_range = Some(self.selected_range.clone());
cx.notify()
}
/// Selects the entire line containing the cursor.
fn select_line(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let offset = self.start_of_line(window, cx);
let end = self.end_of_line(window, cx);
self.move_to(end, window, cx);
self.select_to(offset, window, cx);
}
fn unselect(&mut self, _: &mut Window, cx: &mut Context<Self>) {
let offset = self.next_boundary(self.cursor_offset());
self.selected_range = offset..offset;

View File

@@ -15,7 +15,6 @@ type Suffix = Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>
#[derive(IntoElement)]
pub struct ListItem {
id: ElementId,
base: Stateful<Div>,
disabled: bool,
selected: bool,
@@ -30,8 +29,8 @@ pub struct ListItem {
impl ListItem {
pub fn new(id: impl Into<ElementId>) -> Self {
let id: ElementId = id.into();
Self {
id: id.clone(),
base: h_flex().id(id).gap_x_1().py_1().px_2().text_base(),
disabled: false,
selected: false,
@@ -104,14 +103,14 @@ impl Disableable for ListItem {
}
impl Selectable for ListItem {
fn element_id(&self) -> &ElementId {
&self.id
}
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl Styled for ListItem {

View File

@@ -1,15 +1,15 @@
use std::any::TypeId;
use std::borrow::Cow;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
blue, div, green, px, red, yellow, Animation, AnimationExt, App, AppContext, ClickEvent,
Context, DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
ParentElement as _, Render, SharedString, StatefulInteractiveElement, Styled, Subscription,
Window,
div, px, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context,
DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
Subscription, Window,
};
use smol::Timer;
use theme::ActiveTheme;
@@ -18,13 +18,26 @@ use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonVariants as _};
use crate::{h_flex, v_flex, Icon, IconName, Sizable as _, StyledExt};
#[derive(Debug, Clone, Copy, Default)]
pub enum NotificationType {
#[default]
Info,
Success,
Warning,
Error,
}
impl NotificationType {
fn icon(&self, cx: &App) -> Icon {
match self {
Self::Info => Icon::new(IconName::Info).text_color(cx.theme().element_foreground),
Self::Success => Icon::new(IconName::Info).text_color(cx.theme().secondary_foreground),
Self::Warning => Icon::new(IconName::Warning).text_color(cx.theme().warning_foreground),
Self::Error => Icon::new(IconName::Warning).text_color(cx.theme().danger_foreground),
}
}
}
#[derive(Debug, PartialEq, Clone, Hash, Eq)]
pub(crate) enum NotificationId {
Id(TypeId),
@@ -43,8 +56,6 @@ impl From<(TypeId, ElementId)> for NotificationId {
}
}
type OnClick = Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>;
/// A notification element.
pub struct Notification {
/// The id is used make the notification unique.
@@ -52,48 +63,54 @@ pub struct Notification {
///
/// None means the notification will be added to the end of the list.
id: NotificationId,
kind: NotificationType,
style: StyleRefinement,
type_: Option<NotificationType>,
title: Option<SharedString>,
message: SharedString,
message: Option<SharedString>,
icon: Option<Icon>,
autohide: bool,
on_click: OnClick,
#[allow(clippy::type_complexity)]
action_builder: Option<Rc<dyn Fn(&mut Window, &mut Context<Self>) -> Button>>,
#[allow(clippy::type_complexity)]
content_builder: Option<Rc<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement>>,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
closing: bool,
}
impl From<String> for Notification {
fn from(s: String) -> Self {
Self::new(s)
Self::new().message(s)
}
}
impl From<Cow<'static, str>> for Notification {
fn from(s: Cow<'static, str>) -> Self {
Self::new(s)
Self::new().message(s)
}
}
impl From<SharedString> for Notification {
fn from(s: SharedString) -> Self {
Self::new(s)
Self::new().message(s)
}
}
impl From<&'static str> for Notification {
fn from(s: &'static str) -> Self {
Self::new(s)
Self::new().message(s)
}
}
impl From<(NotificationType, &'static str)> for Notification {
fn from((type_, content): (NotificationType, &'static str)) -> Self {
Self::new(content).with_type(type_)
Self::new().message(content).with_type(type_)
}
}
impl From<(NotificationType, SharedString)> for Notification {
fn from((type_, content): (NotificationType, SharedString)) -> Self {
Self::new(content).with_type(type_)
Self::new().message(content).with_type(type_)
}
}
@@ -103,36 +120,52 @@ impl Notification {
/// Create a new notification with the given content.
///
/// default width is 320px.
pub fn new(message: impl Into<SharedString>) -> Self {
pub fn new() -> Self {
let id: SharedString = uuid::Uuid::new_v4().to_string().into();
let id = (TypeId::of::<DefaultIdType>(), id.into());
Self {
id: id.into(),
style: StyleRefinement::default(),
title: None,
message: message.into(),
kind: NotificationType::Info,
message: None,
type_: None,
icon: None,
autohide: true,
action_builder: None,
content_builder: None,
on_click: None,
closing: false,
}
}
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
self.message = Some(message.into());
self
}
pub fn info(message: impl Into<SharedString>) -> Self {
Self::new(message).with_type(NotificationType::Info)
Self::new()
.message(message)
.with_type(NotificationType::Info)
}
pub fn success(message: impl Into<SharedString>) -> Self {
Self::new(message).with_type(NotificationType::Success)
Self::new()
.message(message)
.with_type(NotificationType::Success)
}
pub fn warning(message: impl Into<SharedString>) -> Self {
Self::new(message).with_type(NotificationType::Warning)
Self::new()
.message(message)
.with_type(NotificationType::Warning)
}
pub fn error(message: impl Into<SharedString>) -> Self {
Self::new(message).with_type(NotificationType::Error)
Self::new()
.message(message)
.with_type(NotificationType::Error)
}
/// Set the type for unique identification of the notification.
@@ -147,8 +180,8 @@ impl Notification {
}
/// Set the type and id of the notification, used to uniquely identify the notification.
pub fn id1<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self {
self.id = (TypeId::of::<T>(), key.into()).into();
pub fn custom_id(mut self, key: impl Into<ElementId>) -> Self {
self.id = (TypeId::of::<DefaultIdType>(), key.into()).into();
self
}
@@ -170,7 +203,7 @@ impl Notification {
/// Set the type of the notification, default is NotificationType::Info.
pub fn with_type(mut self, type_: NotificationType) -> Self {
self.kind = type_;
self.type_ = Some(type_);
self
}
@@ -185,11 +218,21 @@ impl Notification {
mut self,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Arc::new(on_click));
self.on_click = Some(Rc::new(on_click));
self
}
fn dismiss(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context<Self>) {
/// Set the action button of the notification.
pub fn action<F>(mut self, action: F) -> Self
where
F: Fn(&mut Window, &mut Context<Self>) -> Button + 'static,
{
self.action_builder = Some(Rc::new(action));
self
}
/// Dismiss the notification.
pub fn dismiss(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.closing = true;
cx.notify();
@@ -207,84 +250,119 @@ impl Notification {
})
.detach()
}
/// Set the content of the notification.
pub fn content(
mut self,
content: impl Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static,
) -> Self {
self.content_builder = Some(Rc::new(content));
self
}
}
impl Default for Notification {
fn default() -> Self {
Self::new()
}
}
impl EventEmitter<DismissEvent> for Notification {}
impl FluentBuilder for Notification {}
impl Styled for Notification {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl Render for Notification {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let closing = self.closing;
let icon = match self.icon.clone() {
Some(icon) => icon,
None => match self.kind {
NotificationType::Info => Icon::new(IconName::Info).text_color(blue()),
NotificationType::Warning => Icon::new(IconName::Info).text_color(yellow()),
NotificationType::Error => Icon::new(IconName::CloseCircle).text_color(red()),
NotificationType::Success => Icon::new(IconName::CheckCircle).text_color(green()),
},
let icon = match self.type_ {
None => self.icon.clone(),
Some(type_) => Some(type_.icon(cx)),
};
div()
h_flex()
.id("notification")
.refine_style(&self.style)
.group("")
.occlude()
.relative()
.w_72()
.w_96()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.rounded(cx.theme().radius * 1.6)
.shadow_md()
.p_2()
.gap_3()
.child(div().absolute().top_2p5().left_2().child(icon))
.justify_start()
.items_start()
.when_some(icon, |this, icon| {
this.child(div().flex_shrink_0().pt_1().child(icon))
})
.child(
v_flex()
.pl_6()
.flex_1()
.gap_1()
.when_some(self.title.clone(), |this, title| {
this.child(div().text_xs().font_semibold().child(title))
})
.overflow_hidden()
.child(div().text_xs().child(self.message.clone())),
.when_some(self.title.clone(), |this, title| {
this.child(div().text_sm().font_semibold().child(title))
})
.when_some(self.message.clone(), |this, message| {
this.child(div().text_sm().child(message))
})
.when_some(self.content_builder.clone(), |this, child_builder| {
this.child(child_builder(window, cx))
})
.when_some(self.action_builder.clone(), |this, action_builder| {
this.child(action_builder(window, cx).small().w_full().my_2())
}),
)
.child(
div()
.absolute()
.top_2p5()
.right_2p5()
.invisible()
.group_hover("", |this| this.visible())
.child(
Button::new("close")
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(|this, _, window, cx| {
this.dismiss(window, cx);
})),
),
)
.when_some(self.on_click.clone(), |this, on_click| {
this.cursor_pointer()
.on_click(cx.listener(move |view, event, window, cx| {
view.dismiss(event, window, cx);
on_click(event, window, cx);
}))
})
.when(!self.autohide, |this| {
this.child(
h_flex()
.absolute()
.top_1()
.right_1()
.invisible()
.group_hover("", |this| this.visible())
.child(
Button::new("close")
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(Self::dismiss)),
),
)
this.on_click(cx.listener(move |view, event, window, cx| {
view.dismiss(window, cx);
on_click(event, window, cx);
}))
})
.with_animation(
ElementId::NamedInteger("slide-down".into(), closing as u64),
Animation::new(Duration::from_secs_f64(0.15))
Animation::new(Duration::from_secs_f64(0.25))
.with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
move |this, delta| {
if closing {
let x_offset = px(0.) + delta * px(45.);
this.left(px(0.) + x_offset).opacity(1. - delta)
let opacity = 1. - delta;
this.left(px(0.) + x_offset)
.shadow_none()
.opacity(opacity)
.when(opacity < 0.85, |this| this.shadow_none())
} else {
let y_offset = px(-45.) + delta * px(45.);
this.top(px(0.) + y_offset).opacity(delta)
let opacity = delta;
this.top(px(0.) + y_offset)
.opacity(opacity)
.when(opacity < 0.85, |this| this.shadow_none())
}
},
)
@@ -296,7 +374,7 @@ pub struct NotificationList {
/// Notifications that will be auto hidden.
pub(crate) notifications: VecDeque<Entity<Notification>>,
expanded: bool,
subscriptions: HashMap<NotificationId, Subscription>,
_subscriptions: HashMap<NotificationId, Subscription>,
}
impl NotificationList {
@@ -304,16 +382,14 @@ impl NotificationList {
Self {
notifications: VecDeque::new(),
expanded: false,
subscriptions: HashMap::new(),
_subscriptions: HashMap::new(),
}
}
pub fn push(
&mut self,
notification: impl Into<Notification>,
window: &mut Window,
cx: &mut Context<Self>,
) {
pub fn push<T>(&mut self, notification: T, window: &mut Window, cx: &mut Context<Self>)
where
T: Into<Notification>,
{
let notification = notification.into();
let id = notification.id.clone();
let autohide = notification.autohide;
@@ -323,28 +399,47 @@ impl NotificationList {
let notification = cx.new(|_| notification);
self.subscriptions.insert(
self._subscriptions.insert(
id.clone(),
cx.subscribe(&notification, move |view, _, _: &DismissEvent, cx| {
view.notifications.retain(|note| id != note.read(cx).id);
view.subscriptions.remove(&id);
view._subscriptions.remove(&id);
}),
);
self.notifications.push_back(notification.clone());
if autohide {
// Sleep for 3 seconds to autohide the notification
// Sleep for 5 seconds to autohide the notification
cx.spawn_in(window, async move |_, cx| {
Timer::after(Duration::from_secs(3)).await;
_ = notification.update_in(cx, |note, window, cx| {
note.dismiss(&ClickEvent::default(), window, cx)
});
Timer::after(Duration::from_secs(5)).await;
if let Err(error) =
notification.update_in(cx, |note, window, cx| note.dismiss(window, cx))
{
log::error!("Failed to auto hide notification: {error}");
}
})
.detach();
}
cx.notify();
}
pub(crate) fn close<T>(&mut self, key: T, window: &mut Window, cx: &mut Context<Self>)
where
T: Into<ElementId>,
{
let id = (TypeId::of::<DefaultIdType>(), key.into()).into();
if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) {
n.update(cx, |note, cx| {
note.dismiss(window, cx);
});
}
cx.notify();
}
pub fn clear(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.notifications.clear();
cx.notify();
@@ -356,24 +451,25 @@ impl NotificationList {
}
impl Render for NotificationList {
fn render(
&mut self,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let size = window.viewport_size();
let items = self.notifications.iter().rev().take(10).rev().cloned();
div().absolute().top_4().right_4().child(
v_flex()
.id("notification-list")
.h(size.height - px(8.))
.on_hover(cx.listener(|view, hovered, _, cx| {
view.expanded = *hovered;
cx.notify()
}))
.gap_3()
.children(items),
)
div()
.id("notification-wrapper")
.absolute()
.top_4()
.right_4()
.child(
v_flex()
.id("notification-list")
.h(size.height - px(8.))
.gap_3()
.children(items)
.on_hover(cx.listener(|view, hovered, _, cx| {
view.expanded = *hovered;
cx.notify()
})),
)
}
}

View File

@@ -3,10 +3,11 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
actions, anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, Bounds, Context,
Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
IntoElement, KeyBinding, Keystroke, ParentElement, Pixels, Render, ScrollHandle, SharedString,
StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window,
actions, anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, AsKeystroke,
Bounds, Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, KeyBinding, Keystroke, ParentElement, Pixels, Render,
ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity,
Window,
};
use theme::ActiveTheme;
@@ -43,7 +44,7 @@ pub fn init(cx: &mut App) {
]);
}
pub trait PopupMenuExt: Styled + Selectable + IntoElement + 'static {
pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement + 'static {
/// Create a popup menu with the given items, anchored to the TopLeft corner
fn popup_menu(
self,
@@ -59,9 +60,9 @@ pub trait PopupMenuExt: Styled + Selectable + IntoElement + 'static {
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Popover<PopupMenu> {
let style = self.style().clone();
let element_id = self.element_id();
let id = self.interactivity().element_id.clone();
Popover::new(SharedString::from(format!("popup-menu:{element_id:?}")))
Popover::new(SharedString::from(format!("popup-menu:{id:?}")))
.no_style()
.trigger(self)
.trigger_style(style)
@@ -472,7 +473,7 @@ impl PopupMenu {
keybinding
.keystrokes()
.iter()
.map(|key| key_shortcut(key.clone())),
.map(|key| key_shortcut(key.as_keystroke().clone())),
);
return Some(el);

View File

@@ -405,13 +405,14 @@ impl Render for ResizablePanel {
return div();
}
let view = cx.entity().clone();
let total_size = self
.group
.as_ref()
.and_then(|group| group.upgrade())
.map(|group| group.read(cx).total_size());
let view = cx.entity();
div()
.flex()
.flex_grow()

View File

@@ -3,7 +3,7 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
div, AnyView, App, AppContext, Context, Decorations, Entity, FocusHandle, InteractiveElement,
IntoElement, ParentElement as _, Render, Styled, Window,
IntoElement, ParentElement as _, Render, SharedString, Styled, Window,
};
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
@@ -34,6 +34,9 @@ pub trait ContextModal: Sized {
/// Pushes a notification to the notification list.
fn push_notification(&mut self, note: impl Into<Notification>, cx: &mut App);
/// Clears a notification by its ID.
fn clear_notification_by_id(&mut self, id: SharedString, cx: &mut App);
/// Clear all notifications
fn clear_notifications(&mut self, cx: &mut App);
@@ -112,6 +115,15 @@ impl ContextModal for Window {
})
}
fn clear_notification_by_id(&mut self, id: SharedString, cx: &mut App) {
Root::update(self, cx, move |root, window, cx| {
root.notification.update(cx, |view, cx| {
view.close(id.clone(), window, cx);
});
cx.notify();
})
}
fn notifications(&mut self, cx: &mut App) -> Rc<Vec<Entity<Notification>>> {
let entity = Root::read(self, cx).notification.clone();
Rc::new(entity.read(cx).notifications())

View File

@@ -1,21 +1,23 @@
use std::time::Duration;
use gpui::{
bounce, div, ease_in_out, Animation, AnimationExt, Div, IntoElement, ParentElement as _,
RenderOnce, Styled,
bounce, div, ease_in_out, Animation, AnimationExt, IntoElement, RenderOnce, StyleRefinement,
Styled,
};
use theme::ActiveTheme;
use crate::StyledExt;
#[derive(IntoElement)]
pub struct Skeleton {
base: Div,
style: StyleRefinement,
secondary: bool,
}
impl Skeleton {
pub fn new() -> Self {
Self {
base: div().w_full().h_4().rounded_md(),
style: StyleRefinement::default(),
secondary: false,
}
}
@@ -34,7 +36,7 @@ impl Default for Skeleton {
impl Styled for Skeleton {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
&mut self.style
}
}
@@ -46,17 +48,21 @@ impl RenderOnce for Skeleton {
cx.theme().ghost_element_active
};
div().child(
self.base.bg(color).with_animation(
div()
.w_full()
.h_4()
.rounded_md()
.refine_style(&self.style)
.bg(color)
.with_animation(
"skeleton",
Animation::new(Duration::from_secs(2))
Animation::new(Duration::from_secs(3))
.repeat()
.with_easing(bounce(ease_in_out)),
move |this, delta| {
let v = 1.0 - delta * 0.5;
this.opacity(v)
},
),
)
)
}
}

View File

@@ -1,8 +1,6 @@
use std::fmt::{self, Display, Formatter};
use gpui::{
div, px, App, Axis, Div, Element, ElementId, Pixels, Refineable, StyleRefinement, Styled,
};
use gpui::{div, px, App, Axis, Div, Element, Pixels, Refineable, StyleRefinement, Styled};
use serde::{Deserialize, Serialize};
use theme::ActiveTheme;
@@ -105,9 +103,16 @@ impl From<Pixels> for Size {
/// A trait for defining element that can be selected.
pub trait Selectable: Sized {
fn element_id(&self) -> &ElementId;
/// Set the selected state of the element.
fn selected(self, selected: bool) -> Self;
/// Returns true if the element is selected.
fn is_selected(&self) -> bool;
/// Set is the element mouse right clicked, default do nothing.
fn secondary_selected(self, _: bool) -> Self {
self
}
}
/// A trait for defining element that can be disabled.

View File

@@ -11,7 +11,6 @@ pub mod tab_bar;
#[derive(IntoElement)]
pub struct Tab {
id: ElementId,
base: Stateful<Div>,
label: AnyElement,
prefix: Option<AnyElement>,
@@ -25,7 +24,6 @@ impl Tab {
let id: ElementId = id.into();
Self {
id: id.clone(),
base: div().id(id),
label: label.into_any_element(),
disabled: false,
@@ -55,14 +53,14 @@ impl Tab {
}
impl Selectable for Tab {
fn element_id(&self) -> &ElementId {
&self.id
}
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl InteractiveElement for Tab {

View File

@@ -1,7 +1,7 @@
use std::ops::Range;
use std::sync::Arc;
use common::display::DisplayProfile;
use common::display::ReadableProfile;
use gpui::{
AnyElement, AnyView, App, ElementId, HighlightStyle, InteractiveText, IntoElement,
SharedString, StyledText, UnderlineStyle, Window,
@@ -54,7 +54,7 @@ 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>>,
@@ -63,7 +63,7 @@ pub struct RichText {
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,7 +81,7 @@ impl RichText {
text.truncate(text.trim_end().len());
RichText {
RenderedText {
text: SharedString::from(text),
link_urls: link_urls.into(),
link_ranges,
@@ -98,7 +98,7 @@ impl RichText {
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(

View File

@@ -31,6 +31,28 @@ common:
en: "Your Secret Key has been saved"
clear:
en: "Clear"
open_browser:
en: "Open Browser"
refreshed:
en: "Refreshed"
quit:
en: "Quit"
restart:
en: "Restart"
approve:
en: "Approve"
ignore:
en: "Ignore"
relay:
en: "Relay"
relay_invalid:
en: "Relay URL is not valid."
recommended:
en: "Recommended:"
resend:
en: "Resend"
seen_on:
en: "Seen on"
auto_update:
updating:
@@ -40,9 +62,11 @@ auto_update:
user:
dark_mode:
en: "Dark Mode"
en: "Dark mode"
settings:
en: "Settings"
reload_metadata:
en: "Reload metadata"
sign_out:
en: "Sign out"
@@ -58,9 +82,33 @@ onboarding:
auto_login:
en: "Automatically login in the next time"
start_messaging:
en: "Start Messaging"
already_have_account:
en: "Already have an account? Log in."
en: "Start Messaging on Nostr"
nostr_connect:
en: "Continue with Nostr Connect"
scan_qr:
en: "Use Nostr Connect apps to scan the code"
divider:
en: "Already have an account? Continue with"
key_login:
en: "Secret Key or Bunker"
ext_login:
en: "Browser Extension"
ext_login_note:
en: "You will need to keep your default browser open."
proxy:
label:
en: "Waiting for approval"
description:
en: "Open your default browser and approve the connection request in your Nostr Signer extension"
auth:
label:
en: "Authentication Required"
message:
en: "Approve the authentication request to allow Coop to continue sending or receiving events."
requests:
en: "You have %{u} pending authentication requests"
startup:
client_keys_warning:
@@ -100,15 +148,11 @@ login:
title:
en: "Welcome Back!"
key_description:
en: "Continue with Private Key or Bunker URI"
en: "Continue with Private Key or Bunker"
approve_message:
en: "Approve connection request from your signer in %{i} seconds"
nostr_connect:
en: "Continue with Nostr Connect"
scan_qr:
en: "Use Nostr Connect apps to scan the code"
invalid_key:
en: "Please enter a valid private key or Bunker URI to login."
en: "Please enter a valid secret key or bunker to login."
set_password:
en: "Set password to encrypt your key *"
password_to_decrypt:
@@ -128,25 +172,27 @@ login:
key_invalid:
en: "Secret key is invalid"
bunker_invalid:
en: "Bunker URI is not valid"
en: "Bunker is not valid"
logging_in:
en: "Logging in..."
relays:
button_label:
button:
en: "Configure the Messaging Relays to receive messages"
modal_title:
modal:
en: "Set Up Messaging Relays"
description:
en: "In order to receive messages from others, you need to set up at least one Messaging Relay."
add_some_relays:
help_text:
en: "Please add some relays."
invalid:
en: "Relay URL is not valid."
empty:
en: "You need to add at least 1 relay to receive messages."
recommended:
en: "Recommended:"
en: "You need to add at least 1 relay to receive messages from others."
manage_relays:
modal:
en: "Messaging Relay Status"
time:
en: "Last activity: %{t}"
subject:
title:
@@ -179,6 +225,14 @@ screening:
en: "This person is one of your contacts."
not_contact:
en: "This person is not one of your contacts."
active_label:
en: "Activity on Public Relays"
active_tooltip:
en: "This may be inaccurate if the user only publishes to their private relays."
no_active:
en: "This person hasn't had any activity."
active_at:
en: "Last active: %{d}."
mutual_label:
en: "Mutual contacts"
mutual:
@@ -205,8 +259,6 @@ profile:
en: "View Profile"
set_profile_picture:
en: "Set Profile Picture"
placeholder_name:
en: "Alice"
placeholder_bio:
en: "A short introduce about you."
updated_successfully:
@@ -225,40 +277,42 @@ profile:
en: "No bio."
preferences:
media_description:
en: "Coop currently only supports NIP-96 media servers."
backup_description:
en: "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."
screening_description:
en: "When opening a chat request, Coop will show a popup to help you verify the sender."
bypass_description:
en: "Requests from your contacts will automatically go to inbox."
hide_avatar_description:
en: "Unload all avatar pictures to improve performance and reduce memory usage."
proxy_description:
en: "Use wsrv.nl to resize and downscale avatar pictures (saves ~50MB of data)."
account_header:
en: "Account"
see_your_profile:
account_btn:
en: "See your profile"
media_server_header:
en: "Media Server"
url_not_valid:
en: "URL is not valid"
messages_header:
en: "Messages"
backup_messages_label:
relay_and_media:
en: "Relay and Media"
media_description:
en: "Coop currently only supports NIP-96 media servers."
auto_auth:
en: "Automatically authenticate for known relays"
auto_auth_description:
en: "After you approve the authentication request, Coop will automatically complete this step next time."
backup_label:
en: "Backup messages"
backup_description:
en: "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."
screening_label:
en: "Screening"
screening_description:
en: "When opening a chat request, Coop will show a popup to help you verify the sender."
bypass_label:
en: "Skip screening for contacts"
display_header:
en: "Display"
bypass_description:
en: "Requests from your contacts will automatically go to inbox."
hide_avatars_label:
en: "Hide user avatars"
hide_avatar_description:
en: "Unload all avatar pictures to improve performance and reduce memory usage."
proxy_avatars_label:
en: "Proxy user avatars"
proxy_description:
en: "Use wsrv.nl to resize and downscale avatar pictures (saves ~50MB of data)."
messages_header:
en: "Messages"
display_header:
en: "Display"
compose:
placeholder_npub:
@@ -287,33 +341,45 @@ compose:
en: "Subject:"
chat:
private_conversation_notice:
notice:
en: "This conversation is private. Only members can see each other's messages."
placeholder:
en: "Message..."
not_found:
en: "Something is wrong. Coop cannot display this message"
empty_message_error:
en: "Cannot send an empty message"
copy_message_button:
en: "Copy Message"
reply_button:
en: "Reply"
change_subject_button:
en: "Change Subject"
change_subject_modal_title:
reload_tooltip:
en: "Refresh messages"
subject_tooltip:
en: "Change the subject of the conversation"
replying_to_label:
en: "Replying to:"
send_fail:
sent_to:
en: "Sent to:"
sent:
en: "• Sent"
sent_failed:
en: "Failed to send message. Click to see details."
logs_title:
en: "Error Logs"
send_to_label:
en: "Send to:"
sent_success:
en: "Successfully"
reports:
en: "Sent Reports"
nip17_not_found:
en: "%{u} has not set up Messaging Relays, so they won't receive your message."
sidebar:
find_or_start_conversation:
reload_menu:
en: "Reload"
status_menu:
en: "Relay Status"
search_label:
en: "Find or start a conversation"
press_enter_to_search:
search_tooltip:
en: "Press Enter to search"
empty:
en: "There are no users matching query %{query}"
@@ -335,17 +401,17 @@ sidebar:
en: "Incoming new conversations"
trusted_contacts_tooltip:
en: "Only show rooms from trusted contacts"
retrieving_messages:
en: "Retrieving messages"
retrieving_messages_description:
en: "This may take some time"
why_seeing_this_tooltip:
en: "Why you're seeing this"
loading_modal_title:
en: "Retrieving Your Messages"
loading_modal_body_1:
en: "Coop is downloading all your messages from the messaging relays. Depending on your total number of messages, this process may take up to 15 minutes if you're using Nostr Connect."
loading_modal_body_2:
en: "Please be patient - you only need to do this full download once. Next time, Coop will only download new messages."
loading_modal_description:
en: "You still can use the app normally while messages are processing in the background"
no_requests:
en: "No message requests"
no_requests_label:
en: "New message requests from people you don't know will appear here."
no_conversations:
en: "No conversations"
no_conversations_label:
en: "Start a conversation with someone to get started."
loading:
label:
en: "Getting messages. This may take a while..."
tooltip:
en: "The progress runs in the background. It doesn't affect your experience."

View File

@@ -3,7 +3,7 @@
flatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo
arch=$(arch)
fd_version=23.08
fd_version=24.08
flatpak install -y --user org.freedesktop.Platform/${arch}/${fd_version}
flatpak install -y --user org.freedesktop.Sdk/${arch}/${fd_version}
flatpak install -y --user org.freedesktop.Sdk.Extension.rust-stable/${arch}/${fd_version}