Compare commits
19 Commits
0.1.4-alph
...
0.1.5-alph
| Author | SHA1 | Date | |
|---|---|---|---|
| 44f0650617 | |||
|
|
107fedeafd | ||
| 17251be3fd | |||
| 73b2eac080 | |||
| 86eca5803f | |||
| 52a79dca08 | |||
| 87f038248c | |||
|
|
a30f2dcc8a | ||
| 5c5748a80c | |||
|
|
b667dd3f1c | ||
|
|
3246abace1 | ||
|
|
f7610cc9c9 | ||
| 16530a3804 | |||
| b778bb13e4 | |||
|
|
cfc2300c0c | ||
| 42d6328d82 | |||
| 4c9533bfe4 | |||
|
|
00cf7792e5 | ||
| e15cbcc22c |
1388
Cargo.lock
generated
19
Cargo.toml
@@ -1,7 +1,12 @@
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
default-members = ["crates/app"]
|
||||
resolver = "2"
|
||||
members = ["crates/*"]
|
||||
default-members = ["crates/coop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.5"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
coop = { path = "crates/*" }
|
||||
@@ -11,9 +16,11 @@ gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# Nostr
|
||||
nostr-relay-builder = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17" }
|
||||
nostr-connect = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17" }
|
||||
nostr-sdk = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17", features = [
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = ["parser"] }
|
||||
nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-keyring = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
|
||||
"lmdb",
|
||||
"nip96",
|
||||
"nip59",
|
||||
@@ -22,6 +29,8 @@ nostr-sdk = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-n
|
||||
"nip05",
|
||||
] }
|
||||
|
||||
# Others
|
||||
emojis = "0.6.4"
|
||||
smol = "2"
|
||||
oneshot = "0.1.10"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
name = "coop"
|
||||
description = "Coop is a cross-platform Nostr client designed for secure communication focus on simplicity and customizability."
|
||||
product-name = "Coop"
|
||||
version = "0.1.5"
|
||||
category = "SocialNetworking"
|
||||
identifier = "su.reya.coop"
|
||||
version = "0.1.4"
|
||||
resources = ["assets/*/*", "Cargo.toml", "./LICENSE", "./README.md"]
|
||||
icons = [
|
||||
"assets/brand/32x32.png",
|
||||
"assets/brand/128x128.png",
|
||||
"assets/brand/128x128@2x.png",
|
||||
"assets/brand/icon.icns",
|
||||
"assets/brand/icon.ico",
|
||||
]
|
||||
binaries = [ { path = "coop", main = true } ]
|
||||
before-packaging-command = "cargo build --release"
|
||||
out-dir = "./target/release"
|
||||
binaries = [
|
||||
{ path = "coop", main = true },
|
||||
icons = [
|
||||
"crates/coop/resources/32x32.png",
|
||||
"crates/coop/resources/128x128.png",
|
||||
"crates/coop/resources/128x128@2x.png",
|
||||
"crates/coop/resources/app-icon.icns",
|
||||
"crates/coop/resources/app-icon.png",
|
||||
"crates/coop/resources/app-icon.ico",
|
||||
]
|
||||
|
||||
|
Before Width: | Height: | Size: 5.1 KiB |
BIN
assets/brand/avatar.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
3
assets/icons/address-book.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="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>
|
||||
|
After Width: | Height: | Size: 404 B |
@@ -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="m8 10 3.293 3.293a1 1 0 0 0 1.414 0L16 10"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10 5.75 3.75 12 10 18.25M4.5 12h15.75"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 247 B After Width: | Height: | Size: 244 B |
3
assets/icons/arrow-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 5.75 20.25 12 14 18.25M19.5 12H3.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 245 B |
1
assets/icons/arrows-in.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,53.66,163.31,104H192a8,8,0,0,1,0,16H144a8,8,0,0,1-8-8V64a8,8,0,0,1,16,0V92.69l50.34-50.35a8,8,0,0,1,11.32,11.32ZM112,136H64a8,8,0,0,0,0,16H92.69L42.34,202.34a8,8,0,0,0,11.32,11.32L104,163.31V192a8,8,0,0,0,16,0V144A8,8,0,0,0,112,136Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 370 B |
3
assets/icons/bubble-fill.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 488 B |
1
assets/icons/caret-down-fill.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,48,88H208a8,8,0,0,1,5.66,13.66Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 218 B |
3
assets/icons/caret-down.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20 9-6.586 6.586a2 2 0 0 1-2.828 0L4 9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 245 B |
1
assets/icons/caret-right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 249 B |
1
assets/icons/caret-up.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,165.66a8,8,0,0,1-11.32,0L128,91.31,53.66,165.66a8,8,0,0,1-11.32-11.32l80-80a8,8,0,0,1,11.32,0l80,80A8,8,0,0,1,213.66,165.66Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 262 B |
1
assets/icons/check-circle-fill.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm45.66,85.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 298 B |
1
assets/icons/check-circle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 369 B |
1
assets/icons/check.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 245 B |
@@ -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="M9.8 10.25c-1.052 0-1.633 1.221-.97 2.038l2.2 2.707c.5.616 1.44.616 1.94 0l2.2-2.707c.664-.817.082-2.038-.97-2.038H9.8Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 257 B |
@@ -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="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm3.58 7.975a.75.75 0 0 0-1.16-.95l-3.976 4.859L9.03 12.47a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.11-.055l4.5-5.5Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 365 B |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 429 B |
3
assets/icons/close-circle.svg
Normal 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 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 12Zm7.53-3.53a.75.75 0 0 0-1.06 1.06L10.94 12l-2.47 2.47a.75.75 0 1 0 1.06 1.06L12 13.06l2.47 2.47a.75.75 0 1 0 1.06-1.06L13.06 12l2.47-2.47a.75.75 0 0 0-1.06-1.06L12 10.94 9.53 8.47Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -1,3 +1 @@
|
||||
<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="M4.116 4.116a1.25 1.25 0 0 1 1.768 0L12 10.232l6.116-6.116a1.25 1.25 0 0 1 1.768 1.768L13.768 12l6.116 6.116a1.25 1.25 0 0 1-1.768 1.768L12 13.768l-6.116 6.116a1.25 1.25 0 0 1-1.768-1.768L10.232 12 4.116 5.884a1.25 1.25 0 0 1 0-1.768Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"></path></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 412 B After Width: | Height: | Size: 314 B |
@@ -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="M19.432 2.738c.505.54.728 1.327.443 2.133-.606 1.713-1.798 3.124-2.797 4.087a15.74 15.74 0 0 1-1.045.921l.137.1c.93.684 1.416 1.975.757 3.118-1.221 2.12-4.356 5.803-11.192 5.803a.753.753 0 0 1-.15-.015A32.702 32.702 0 0 0 5.5 21.25a.75.75 0 0 1-1.5 0c0-4.43.821-8.93 2.909-12.485 2.106-3.587 5.49-6.182 10.492-6.749a2.404 2.404 0 0 1 2.031.722Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 522 B |
4
assets/icons/edit-fill.svg
Normal 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="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>
|
||||
|
After Width: | Height: | Size: 622 B |
3
assets/icons/emoji-fill.svg
Normal 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 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 12Zm7.75-5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 9.75 7Zm4.5 0a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5a.75.75 0 0 1 .75-.75Zm-6.143 7.864a.75.75 0 0 1 1.029-.257c1.04.624 1.97.905 2.864.905.894 0 1.824-.281 2.864-.905a.75.75 0 1 1 .772 1.286c-1.21.726-2.405 1.12-3.636 1.12-1.23 0-2.426-.394-3.636-1.12a.75.75 0 0 1-.257-1.029Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
@@ -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 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10ZM8.405 10.2a.75.75 0 0 1-1.12.263l-2.428-1.82a.707.707 0 0 1-.204-.92 8.496 8.496 0 0 1 8.675-4.12c.409.064.653.475.553.877l-.77 3.084a.75.75 0 0 1-.545.546l-3.226.81a.75.75 0 0 0-.487.39l-.448.889Zm6.805 6.385a.75.75 0 0 1-.671.415h-2.135a.75.75 0 0 1-.624-.334l-1.436-2.153a.75.75 0 0 1 .095-.948l.433-.431a.75.75 0 0 1 .577-.217l1.403.09a.75.75 0 0 1 .37.125l2.233 1.5a.75.75 0 0 1 .252.958l-.498.995Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 654 B |
3
assets/icons/folder.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="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>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -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" d="M3.999 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm9.499.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0ZM7.999 12c-1.765 0-3.236.635-4.365 1.72-1.117 1.074-1.868 2.557-2.282 4.225C.932 19.64 2.351 21 3.9 21h8.197c1.55 0 2.968-1.361 2.548-3.055-.413-1.668-1.164-3.151-2.281-4.225-1.13-1.085-2.6-1.72-4.365-1.72Zm6.174.715c1.21 1.337 1.983 3.011 2.414 4.749.231.934.167 1.79-.103 2.536h3.86c1.538 0 2.996-1.365 2.51-3.075C22.06 14.14 20.103 12 16.997 12c-1.08 0-2.023.26-2.825.715Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 595 B |
@@ -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="M17.75 19.25h2.596c1.163 0 2.106-1.001 1.788-2.12-.733-2.573-2.465-4.38-5.134-4.38-.446 0-.866.05-1.26.147M11.25 7a3.25 3.25 0 1 1-6.5 0 3.25 3.25 0 0 1 6.5 0Zm8.5.5a2.75 2.75 0 1 1-5.5 0 2.75 2.75 0 0 1 5.5 0ZM2.08 18.126c.78-3.14 2.78-5.376 5.92-5.376s5.14 2.237 5.918 5.376c.28 1.128-.658 2.124-1.82 2.124H3.901c-1.162 0-2.1-.996-1.82-2.124Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 550 B |
1
assets/icons/mailbox-fill.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -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" fill-rule="evenodd" d="M3 13.745V5.75A2.75 2.75 0 0 1 5.75 3h12.5A2.75 2.75 0 0 1 21 5.75v12.5A2.75 2.75 0 0 1 18.25 21H5.75A2.75 2.75 0 0 1 3 18.25M5.75 4.5h12.5c.69 0 1.25.56 1.25 1.25V13h-3.57a.75.75 0 0 0-.737.61 3.251 3.251 0 0 1-6.386 0A.75.75 0 0 0 8.07 13H4.5V5.75c0-.69.56-1.25 1.25-1.25Z" clip-rule="evenodd"/>
|
||||
<path fill="currentColor" d="M3 18.25v-4.505"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 502 B |
1
assets/icons/plus-circle-fill.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.13,104.13,0,0,0,128,24Zm40,112H136v32a8,8,0,0,1-16,0V136H88a8,8,0,0,1,0-16h32V88a8,8,0,0,1,16,0v32h32a8,8,0,0,1,0,16Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 281 B |
@@ -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="M12 19.25V13m0 0 2.5 2.5M12 13l-2.5 2.5m-2.125 3.75H4.75a2 2 0 0 1-2-2V5.75a2 2 0 0 1 2-2h4.18a2 2 0 0 1 1.664.89l1.11 1.665a1 1 0 0 0 .831.445h6.715a2 2 0 0 1 2 2v8.5a2 2 0 0 1-2 2h-2.625"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M2 4.75A2.75 2.75 0 0 1 4.75 2h8.5A2.75 2.75 0 0 1 16 4.75V8h3.25A2.75 2.75 0 0 1 22 10.75v8.5A2.75 2.75 0 0 1 19.25 22h-8.5A2.75 2.75 0 0 1 8 19.25V16H4.75A2.75 2.75 0 0 1 2 13.25v-8.5ZM14.5 8V4.75c0-.69-.56-1.25-1.25-1.25h-8.5c-.69 0-1.25.56-1.25 1.25v5.991l.983-.644a2.75 2.75 0 0 1 3.033.012l.5.334A2.75 2.75 0 0 1 10.75 8h3.75ZM5 6.25a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0Zm8.39 6.292a.75.75 0 0 1 .766.027l2.8 1.8a.75.75 0 0 1 0 1.262l-2.8 1.8A.75.75 0 0 1 13 16.8v-3.6a.75.75 0 0 1 .39-.658Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 394 B After Width: | Height: | Size: 682 B |
1
assets/icons/users-three-fill.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 676 B |
17
crates/account/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "account"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ui = { path = "../ui" }
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
230
crates/account/src/lib.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use global::{
|
||||
constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID},
|
||||
get_client,
|
||||
};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
||||
use nostr_sdk::prelude::*;
|
||||
use ui::{notification::Notification, ContextModal};
|
||||
|
||||
struct GlobalAccount(Entity<Account>);
|
||||
|
||||
impl Global for GlobalAccount {}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
Account::set_global(cx.new(|_| Account { profile: None }), cx);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Account {
|
||||
pub profile: Option<Profile>,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalAccount>().0.clone()
|
||||
}
|
||||
|
||||
pub fn set_global(account: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAccount(account));
|
||||
}
|
||||
|
||||
/// Login to the account using the given signer.
|
||||
pub fn login<S>(&mut self, signer: S, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
S: NostrSigner + 'static,
|
||||
{
|
||||
let task: Task<Result<Profile, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
// Use user's signer for main signer
|
||||
_ = client.set_signer(signer).await;
|
||||
|
||||
// Verify nostr signer and get public key
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
log::info!("Logged in with public key: {:?}", public_key);
|
||||
|
||||
// Fetch user's metadata
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| match task.await {
|
||||
Ok(profile) => {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.profile(profile, cx);
|
||||
|
||||
cx.defer_in(window, |this, _, cx| {
|
||||
this.subscribe(cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Create a new account with the given metadata.
|
||||
pub fn new_account(&mut self, metadata: Metadata, window: &mut Window, cx: &mut Context<Self>) {
|
||||
const DEFAULT_NIP_65_RELAYS: [&str; 4] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nostr.net",
|
||||
"wss://nos.lol",
|
||||
];
|
||||
|
||||
const DEFAULT_MESSAGING_RELAYS: [&str; 2] =
|
||||
["wss://auth.nostr1.com", "wss://relay.0xchat.com"];
|
||||
|
||||
let keys = Keys::generate();
|
||||
let public_key = keys.public_key();
|
||||
|
||||
let task: Task<Result<Profile, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
|
||||
// Update signer
|
||||
client.set_signer(keys).await;
|
||||
|
||||
// Set metadata
|
||||
client.set_metadata(&metadata).await?;
|
||||
|
||||
// Create relay list
|
||||
let tags: Vec<Tag> = DEFAULT_NIP_65_RELAYS
|
||||
.into_iter()
|
||||
.filter_map(|url| {
|
||||
if let Ok(url) = RelayUrl::parse(url) {
|
||||
Some(Tag::relay_metadata(url, None))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let builder = EventBuilder::new(Kind::RelayList, "").tags(tags);
|
||||
|
||||
if let Err(e) = client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send relay list event: {}", e);
|
||||
};
|
||||
|
||||
// Create messaging relay list
|
||||
let tags: Vec<Tag> = DEFAULT_MESSAGING_RELAYS
|
||||
.into_iter()
|
||||
.filter_map(|url| {
|
||||
if let Ok(url) = RelayUrl::parse(url) {
|
||||
Some(Tag::relay(url))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
|
||||
|
||||
if let Err(e) = client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send messaging relay list event: {}", e);
|
||||
};
|
||||
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(profile) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.profile(profile, cx);
|
||||
|
||||
cx.defer_in(window, |this, _, cx| {
|
||||
this.subscribe(cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error("Failed to create account."), cx)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Sets the profile for the account.
|
||||
pub fn profile(&mut self, profile: Profile, cx: &mut Context<Self>) {
|
||||
self.profile = Some(profile);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Subscribes to the current account's metadata.
|
||||
pub fn subscribe(&self, cx: &mut Context<Self>) {
|
||||
let Some(profile) = self.profile.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let user = profile.public_key();
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
let metadata = Filter::new()
|
||||
.kinds(vec![
|
||||
Kind::Metadata,
|
||||
Kind::ContactList,
|
||||
Kind::InboxRelays,
|
||||
Kind::MuteList,
|
||||
Kind::SimpleGroups,
|
||||
])
|
||||
.author(user)
|
||||
.limit(10);
|
||||
|
||||
let data = Filter::new()
|
||||
.author(user)
|
||||
.since(Timestamp::now())
|
||||
.kinds(vec![
|
||||
Kind::Metadata,
|
||||
Kind::ContactList,
|
||||
Kind::MuteList,
|
||||
Kind::SimpleGroups,
|
||||
Kind::InboxRelays,
|
||||
Kind::RelayList,
|
||||
]);
|
||||
|
||||
let msg = Filter::new().kind(Kind::GiftWrap).pubkey(user);
|
||||
let new_msg = Filter::new().kind(Kind::GiftWrap).pubkey(user).limit(0);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
client.subscribe(metadata, Some(opts)).await?;
|
||||
client.subscribe(data, None).await?;
|
||||
|
||||
let sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
|
||||
client.subscribe_with_id(sub_id, msg, Some(opts)).await?;
|
||||
|
||||
let sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
client.subscribe_with_id(sub_id, new_msg, None).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn(async move |_, _| {
|
||||
if let Err(e) = task.await {
|
||||
log::error!("Error: {}", e);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
@@ -1,916 +0,0 @@
|
||||
use std::{collections::HashSet, str::FromStr, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::profile::NostrProfile;
|
||||
use global::{
|
||||
constants::{
|
||||
ALL_MESSAGES_SUB_ID, CLIENT_KEYRING, DEVICE_ANNOUNCEMENT_KIND, DEVICE_REQUEST_KIND,
|
||||
DEVICE_RESPONSE_KIND, DEVICE_SUB_ID, MASTER_KEYRING, NEW_MESSAGE_SUB_ID,
|
||||
},
|
||||
get_app_name, get_client, get_device_keys, get_device_name, set_device_keys,
|
||||
};
|
||||
use gpui::{
|
||||
div, px, relative, App, AppContext, Context, Entity, Global, ParentElement, Styled, Task,
|
||||
Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
indicator::Indicator,
|
||||
notification::Notification,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Root, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
use crate::views::{app, onboarding, relays};
|
||||
|
||||
struct GlobalDevice(Entity<Device>);
|
||||
|
||||
impl Global for GlobalDevice {}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub enum DeviceState {
|
||||
Master,
|
||||
Minion,
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
|
||||
impl DeviceState {
|
||||
pub fn subscribe(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match self {
|
||||
Self::Master => {
|
||||
let client = get_client();
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let opts =
|
||||
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_REQUEST_KIND))
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Subscribe for the latest request
|
||||
client.subscribe(filter, Some(opts)).await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_REQUEST_KIND))
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
|
||||
// Subscribe for new device requests
|
||||
client.subscribe(filter, None).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, |_, _cx| async move {
|
||||
if let Err(err) = task.await {
|
||||
log::error!("Failed to subscribe for device requests: {}", err);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
Self::Minion => {
|
||||
let client = get_client();
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let opts =
|
||||
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_RESPONSE_KIND))
|
||||
.author(public_key);
|
||||
|
||||
// Getting all previous approvals
|
||||
client.subscribe(filter.clone(), Some(opts)).await?;
|
||||
|
||||
// Continously receive the request approval
|
||||
client
|
||||
.subscribe(filter.since(Timestamp::now()), None)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, |_, _cx| async move {
|
||||
if let Err(err) = task.await {
|
||||
log::error!("Failed to subscribe for device approval: {}", err);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current Device (Client)
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
#[derive(Debug)]
|
||||
pub struct Device {
|
||||
/// Profile (Metadata) of current user
|
||||
profile: Option<NostrProfile>,
|
||||
/// Client Keys
|
||||
client_keys: Arc<Keys>,
|
||||
/// Device State
|
||||
state: Entity<DeviceState>,
|
||||
requesters: Entity<HashSet<PublicKey>>,
|
||||
is_processing: bool,
|
||||
}
|
||||
|
||||
pub fn init(window: &mut Window, cx: &App) {
|
||||
// Initialize client keys
|
||||
let read_keys = cx.read_credentials(CLIENT_KEYRING);
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
cx.spawn(|cx| async move {
|
||||
let client_keys = if let Ok(Some((_, secret))) = read_keys.await {
|
||||
let secret_key = SecretKey::from_slice(&secret).unwrap();
|
||||
|
||||
Arc::new(Keys::new(secret_key))
|
||||
} else {
|
||||
// Generate new keys and save them to keyring
|
||||
let keys = Keys::generate();
|
||||
|
||||
if let Ok(write_keys) = cx.update(|cx| {
|
||||
cx.write_credentials(
|
||||
CLIENT_KEYRING,
|
||||
keys.public_key.to_hex().as_str(),
|
||||
keys.secret_key().as_secret_bytes(),
|
||||
)
|
||||
}) {
|
||||
_ = write_keys.await;
|
||||
};
|
||||
|
||||
Arc::new(keys)
|
||||
};
|
||||
|
||||
cx.update(|cx| {
|
||||
let state = cx.new(|_| DeviceState::None);
|
||||
let weak_state = state.downgrade();
|
||||
let requesters = cx.new(|_| HashSet::new());
|
||||
let entity = cx.new(|_| Device {
|
||||
profile: None,
|
||||
is_processing: false,
|
||||
state,
|
||||
client_keys,
|
||||
requesters,
|
||||
});
|
||||
|
||||
window_handle
|
||||
.update(cx, |_, window, cx| {
|
||||
// Open the onboarding view
|
||||
Root::update(window, cx, |this, window, cx| {
|
||||
this.replace_view(onboarding::init(window, cx).into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Observe the DeviceState changes
|
||||
if let Some(state) = weak_state.upgrade() {
|
||||
window
|
||||
.observe(&state, cx, |this, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.subscribe(window, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
};
|
||||
|
||||
// Observe the Device changes
|
||||
window
|
||||
.observe(&entity, cx, |this, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.on_device_change(window, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
|
||||
Device::set_global(entity, cx)
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
impl Device {
|
||||
pub fn global(cx: &App) -> Option<Entity<Self>> {
|
||||
cx.try_global::<GlobalDevice>().map(|model| model.0.clone())
|
||||
}
|
||||
|
||||
pub fn set_global(device: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalDevice(device));
|
||||
}
|
||||
|
||||
pub fn client_keys(&self) -> Arc<Keys> {
|
||||
self.client_keys.clone()
|
||||
}
|
||||
|
||||
pub fn profile(&self) -> Option<&NostrProfile> {
|
||||
self.profile.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_profile(&mut self, profile: NostrProfile, cx: &mut Context<Self>) {
|
||||
self.profile = Some(profile);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_state(&mut self, state: DeviceState, cx: &mut Context<Self>) {
|
||||
self.state.update(cx, |this, cx| {
|
||||
*this = state;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_processing(&mut self, is_processing: bool, cx: &mut Context<Self>) {
|
||||
self.is_processing = is_processing;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn add_requester(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
self.requesters.update(cx, |this, cx| {
|
||||
this.insert(public_key);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Login and set user signer
|
||||
pub fn login<T>(&self, signer: T, cx: &mut Context<Self>) -> Task<Result<(), Error>>
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
let client = get_client();
|
||||
|
||||
// Set the user's signer as the main signer
|
||||
let login: Task<Result<NostrProfile, Error>> = cx.background_spawn(async move {
|
||||
// Use user's signer for main signer
|
||||
_ = client.set_signer(signer).await;
|
||||
|
||||
// Verify nostr signer and get public key
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Fetch user's metadata
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
// Get user's inbox relays
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let relays = if let Some(event) = client
|
||||
.fetch_events(filter, Duration::from_secs(2))
|
||||
.await?
|
||||
.first_owned()
|
||||
{
|
||||
let relays = event
|
||||
.tags
|
||||
.filter_standardized(TagKind::Relay)
|
||||
.filter_map(|t| {
|
||||
if let TagStandard::Relay(url) = t {
|
||||
Some(url.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<SmallVec<[RelayUrl; 3]>>();
|
||||
|
||||
Some(relays)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let profile = NostrProfile::new(public_key, metadata).relays(relays);
|
||||
|
||||
Ok(profile)
|
||||
});
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
match login.await {
|
||||
Ok(user) => {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.profile = Some(user);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// This function is called whenever the device is changed
|
||||
fn on_device_change(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(profile) = self.profile.as_ref() else {
|
||||
// User not logged in, render the Onboarding View
|
||||
Root::update(window, cx, |this, window, cx| {
|
||||
this.replace_view(onboarding::init(window, cx).into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
// Replace the Onboarding View with the Dock View
|
||||
Root::update(window, cx, |this, window, cx| {
|
||||
this.replace_view(app::init(window, cx).into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Get the user's messaging relays
|
||||
// If it is empty, user must setup relays
|
||||
let ready = profile.messaging_relays.is_some();
|
||||
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
cx.update(|window, cx| {
|
||||
if !ready {
|
||||
this.update(cx, |this, cx| {
|
||||
this.render_setup_relays(window, cx);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.start_subscription(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Initialize subscription for current user
|
||||
pub fn start_subscription(&self, cx: &Context<Self>) {
|
||||
if self.is_processing {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(profile) = self.profile() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let user = profile.public_key;
|
||||
let client = get_client();
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let device_kind = Kind::Custom(DEVICE_ANNOUNCEMENT_KIND);
|
||||
|
||||
// Create a device announcement filter
|
||||
let device = Filter::new().kind(device_kind).author(user).limit(1);
|
||||
|
||||
// Create a contact list filter
|
||||
let contacts = Filter::new().kind(Kind::ContactList).author(user).limit(1);
|
||||
|
||||
// Create a user's data filter
|
||||
let data = Filter::new()
|
||||
.author(user)
|
||||
.since(Timestamp::now())
|
||||
.kinds(vec![
|
||||
Kind::Metadata,
|
||||
Kind::InboxRelays,
|
||||
Kind::RelayList,
|
||||
device_kind,
|
||||
]);
|
||||
|
||||
// Create a filter for getting all gift wrapped events send to current user
|
||||
let msg = Filter::new().kind(Kind::GiftWrap).pubkey(user);
|
||||
|
||||
// Create a filter to continuously receive new messages.
|
||||
let new_msg = Filter::new().kind(Kind::GiftWrap).pubkey(user).limit(0);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
// Only subscribe to the latest device announcement
|
||||
let sub_id = SubscriptionId::new(DEVICE_SUB_ID);
|
||||
client.subscribe_with_id(sub_id, device, Some(opts)).await?;
|
||||
|
||||
// Only subscribe to the latest contact list
|
||||
client.subscribe(contacts, Some(opts)).await?;
|
||||
|
||||
// Continuously receive new user's data since now
|
||||
client.subscribe(data, None).await?;
|
||||
|
||||
let sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
|
||||
client.subscribe_with_id(sub_id, msg, Some(opts)).await?;
|
||||
|
||||
let sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
client.subscribe_with_id(sub_id, new_msg, None).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn(|_, _| async move {
|
||||
if let Err(e) = task.await {
|
||||
log::error!("Subscription error: {}", e);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Setup Device
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn setup_device(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(profile) = self.profile().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// If processing, return early
|
||||
if self.is_processing {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process if device keys are not set
|
||||
self.set_processing(true, cx);
|
||||
|
||||
let client = get_client();
|
||||
let public_key = profile.public_key;
|
||||
let kind = Kind::Custom(DEVICE_ANNOUNCEMENT_KIND);
|
||||
let filter = Filter::new().kind(kind).author(public_key).limit(1);
|
||||
|
||||
// Fetch device announcement events
|
||||
let fetch_announcement = cx.background_spawn(async move {
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
Ok(event)
|
||||
} else {
|
||||
Err(anyhow!("Device Announcement not found."))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
// Device Keys has been set, no need to retrieve device announcement again
|
||||
if get_device_keys().await.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
match fetch_announcement.await {
|
||||
Ok(event) => {
|
||||
log::info!("Found a device announcement: {:?}", event);
|
||||
|
||||
let n_tag = event
|
||||
.tags
|
||||
.find(TagKind::custom("n"))
|
||||
.and_then(|t| t.content())
|
||||
.map(|hex| hex.to_owned());
|
||||
|
||||
let credentials_task =
|
||||
match cx.update(|_, cx| cx.read_credentials(MASTER_KEYRING)) {
|
||||
Ok(task) => task,
|
||||
Err(err) => {
|
||||
log::error!("Failed to read credentials: {:?}", err);
|
||||
log::info!("Trying to request keys from Master Device...");
|
||||
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.request_master_keys(window, cx);
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match credentials_task.await {
|
||||
Ok(Some((pubkey, secret))) if n_tag.as_deref() == Some(&pubkey) => {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_master_keys(secret, window, cx);
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
_ => {
|
||||
log::info!("This device is not the Master Device.");
|
||||
log::info!("Trying to request keys from Master Device...");
|
||||
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.request_master_keys(window, cx);
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
log::info!("Device Announcement not found.");
|
||||
log::info!("Appoint this device as Master Device.");
|
||||
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_new_master_keys(window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Create a new Master Keys, appointing this device as Master Device.
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn set_new_master_keys(&self, window: &mut Window, cx: &Context<Self>) {
|
||||
let client = get_client();
|
||||
let app_name = get_app_name();
|
||||
|
||||
let task: Task<Result<Arc<Keys>, Error>> = cx.background_spawn(async move {
|
||||
let keys = Keys::generate();
|
||||
let kind = Kind::Custom(DEVICE_ANNOUNCEMENT_KIND);
|
||||
let client_tag = Tag::client(app_name);
|
||||
let pubkey_tag = Tag::custom(TagKind::custom("n"), vec![keys.public_key().to_hex()]);
|
||||
|
||||
let event = EventBuilder::new(kind, "").tags(vec![client_tag, pubkey_tag]);
|
||||
|
||||
if let Err(e) = client.send_event_builder(event).await {
|
||||
log::error!("Failed to send Device Announcement: {}", e);
|
||||
} else {
|
||||
log::info!("Device Announcement has been sent");
|
||||
}
|
||||
|
||||
Ok(Arc::new(keys))
|
||||
});
|
||||
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
if get_device_keys().await.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(keys) = task.await {
|
||||
// Update global state
|
||||
set_device_keys(keys.clone()).await;
|
||||
|
||||
// Save keys
|
||||
if let Ok(task) = cx.update(|_, cx| {
|
||||
cx.write_credentials(
|
||||
MASTER_KEYRING,
|
||||
keys.public_key().to_hex().as_str(),
|
||||
keys.secret_key().as_secret_bytes(),
|
||||
)
|
||||
}) {
|
||||
if let Err(e) = task.await {
|
||||
log::error!("Failed to write device keys to keyring: {}", e);
|
||||
}
|
||||
};
|
||||
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(DeviceState::Master, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Device already has Master Keys, re-appointing this device as Master Device.
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn set_master_keys(&self, secret: Vec<u8>, window: &mut Window, cx: &Context<Self>) {
|
||||
let Ok(secret_key) = SecretKey::from_slice(&secret) else {
|
||||
log::error!("Failed to parse secret key");
|
||||
return;
|
||||
};
|
||||
let keys = Arc::new(Keys::new(secret_key));
|
||||
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
log::info!("Re-appointing this device as Master Device.");
|
||||
set_device_keys(keys).await;
|
||||
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(DeviceState::Master, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Send a request to ask for device keys from the other Nostr client
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn request_master_keys(&self, window: &mut Window, cx: &Context<Self>) {
|
||||
let client = get_client();
|
||||
let app_name = get_app_name();
|
||||
let client_keys = self.client_keys.clone();
|
||||
|
||||
let kind = Kind::Custom(DEVICE_REQUEST_KIND);
|
||||
let client_tag = Tag::client(app_name);
|
||||
let pubkey_tag = Tag::custom(
|
||||
TagKind::custom("pubkey"),
|
||||
vec![client_keys.public_key().to_hex()],
|
||||
);
|
||||
|
||||
// Create a request event builder
|
||||
let builder = EventBuilder::new(kind, "").tags(vec![client_tag, pubkey_tag]);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
log::info!("Sent a request to ask for device keys from the other Nostr client");
|
||||
|
||||
if let Err(e) = client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send device keys request: {}", e);
|
||||
} else {
|
||||
log::info!("Waiting for response...");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(DeviceState::Minion, cx);
|
||||
this.render_waiting_modal(window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Received Device Keys approval from Master Device,
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn recv_approval(&self, event: Event, window: &mut Window, cx: &Context<Self>) {
|
||||
let local_signer = self.client_keys.clone();
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
if let Some(tag) = event
|
||||
.tags
|
||||
.find(TagKind::custom("P"))
|
||||
.and_then(|tag| tag.content())
|
||||
{
|
||||
if let Ok(public_key) = PublicKey::from_str(tag) {
|
||||
let secret = local_signer
|
||||
.nip44_decrypt(&public_key, &event.content)
|
||||
.await?;
|
||||
|
||||
let keys = Arc::new(Keys::parse(&secret)?);
|
||||
|
||||
// Update global state with new device keys
|
||||
set_device_keys(keys).await;
|
||||
log::info!("Received master keys");
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Public Key is invalid"))
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("Failed to decrypt the Master Keys"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, |_, mut cx| async move {
|
||||
// No need to update if device keys are already available
|
||||
if get_device_keys().await.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(format!("Failed to decrypt: {}", e)),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
cx.update(|window, cx| {
|
||||
window.close_all_modals(cx);
|
||||
window.push_notification(
|
||||
Notification::success("Device Keys request has been approved"),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Received Master Keys request from other Nostr client
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn recv_request(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(target_pubkey) = event
|
||||
.tags
|
||||
.find(TagKind::custom("pubkey"))
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
else {
|
||||
log::error!("Invalid public key.");
|
||||
return;
|
||||
};
|
||||
|
||||
// Prevent processing duplicate requests
|
||||
if self.requesters.read(cx).contains(&target_pubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.add_requester(target_pubkey, cx);
|
||||
|
||||
let client = get_client();
|
||||
let read_keys = cx.read_credentials(MASTER_KEYRING);
|
||||
let local_signer = self.client_keys.clone();
|
||||
|
||||
let device_name = event
|
||||
.tags
|
||||
.find(TagKind::Client)
|
||||
.and_then(|tag| tag.content())
|
||||
.unwrap_or("Other Device")
|
||||
.to_owned();
|
||||
|
||||
let response = window.prompt(
|
||||
gpui::PromptLevel::Info,
|
||||
"Requesting Device Keys",
|
||||
Some(
|
||||
format!(
|
||||
"{} is requesting shared device keys stored in this device",
|
||||
device_name
|
||||
)
|
||||
.as_str(),
|
||||
),
|
||||
&["Approve", "Deny"],
|
||||
cx,
|
||||
);
|
||||
|
||||
cx.spawn_in(window, |_, cx| async move {
|
||||
match response.await {
|
||||
Ok(0) => {
|
||||
if let Ok(Some((_, secret))) = read_keys.await {
|
||||
let local_pubkey = local_signer.get_public_key().await?;
|
||||
|
||||
// Get device's secret key
|
||||
let device_secret = SecretKey::from_slice(&secret)?;
|
||||
let device_secret_hex = device_secret.to_secret_hex();
|
||||
|
||||
// Encrypt device's secret key by using NIP-44
|
||||
let content = local_signer
|
||||
.nip44_encrypt(&target_pubkey, &device_secret_hex)
|
||||
.await?;
|
||||
|
||||
// Create pubkey tag for other device (lowercase p)
|
||||
let other_tag = Tag::public_key(target_pubkey);
|
||||
|
||||
// Create pubkey tag for this device (uppercase P)
|
||||
let local_tag = Tag::custom(
|
||||
TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::P)),
|
||||
vec![local_pubkey.to_hex()],
|
||||
);
|
||||
|
||||
// Create event builder
|
||||
let kind = Kind::Custom(DEVICE_RESPONSE_KIND);
|
||||
let tags = vec![other_tag, local_tag];
|
||||
let builder = EventBuilder::new(kind, content).tags(tags);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Err(err) = client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send device keys to other client: {}", err);
|
||||
} else {
|
||||
log::info!("Sent device keys to other client");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Device Keys not found"))
|
||||
}
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Show setup relays modal
|
||||
///
|
||||
/// NIP-17: <https://github.com/nostr-protocol/nips/blob/master/17.md>
|
||||
pub fn render_setup_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = relays::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, window, cx| {
|
||||
let is_loading = relays.read(cx).loading();
|
||||
|
||||
this.keyboard(false)
|
||||
.closable(false)
|
||||
.width(px(430.))
|
||||
.title("Your Messaging Relays are not configured")
|
||||
.child(relays.clone())
|
||||
.footer(
|
||||
div()
|
||||
.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.child(
|
||||
Button::new("update_inbox_relays_btn")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Large)
|
||||
.w_full()
|
||||
.loading(is_loading)
|
||||
.on_click(window.listener_for(&relays, |this, _, window, cx| {
|
||||
this.update(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Show waiting modal
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn render_waiting_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
let msg = format!(
|
||||
"Please open {} and approve sharing device keys request.",
|
||||
get_device_name()
|
||||
);
|
||||
|
||||
this.keyboard(false)
|
||||
.closable(false)
|
||||
.width(px(430.))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size_full()
|
||||
.p_4()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.child("You're using a new device."),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(
|
||||
cx.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.line_height(relative(1.3))
|
||||
.child(msg),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.footer(
|
||||
div()
|
||||
.p_4()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.w_full()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(Indicator::new().small())
|
||||
.child("Waiting for approval ..."),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
use anyhow::anyhow;
|
||||
use asset::Assets;
|
||||
use chats::registry::ChatRegistry;
|
||||
use device::Device;
|
||||
use futures::{select, FutureExt};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use global::constants::APP_NAME;
|
||||
use global::{
|
||||
constants::{
|
||||
ALL_MESSAGES_SUB_ID, APP_ID, BOOTSTRAP_RELAYS, DEVICE_ANNOUNCEMENT_KIND,
|
||||
DEVICE_REQUEST_KIND, DEVICE_RESPONSE_KIND, DEVICE_SUB_ID, NEW_MESSAGE_SUB_ID,
|
||||
},
|
||||
get_client, get_device_keys, set_device_name,
|
||||
};
|
||||
use gpui::{
|
||||
actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
|
||||
WindowBounds, WindowKind, WindowOptions,
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use gpui::{point, SharedString, TitlebarOptions};
|
||||
#[cfg(target_os = "linux")]
|
||||
use gpui::{WindowBackgroundAppearance, WindowDecorations};
|
||||
use nostr_sdk::{
|
||||
nips::nip59::UnwrappedGift, pool::prelude::ReqExitPolicy, Event, Filter, Keys, Kind, PublicKey,
|
||||
RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions, SubscriptionId, TagKind,
|
||||
};
|
||||
use smol::Timer;
|
||||
use std::{collections::HashSet, mem, sync::Arc, time::Duration};
|
||||
use ui::{theme::Theme, Root};
|
||||
use views::startup;
|
||||
|
||||
pub(crate) mod asset;
|
||||
pub(crate) mod device;
|
||||
pub(crate) mod views;
|
||||
|
||||
actions!(coop, [Quit]);
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Signal {
|
||||
/// Receive event
|
||||
Event(Event),
|
||||
/// Receive request master key event
|
||||
RequestMasterKey(Event),
|
||||
/// Receive approve master key event
|
||||
ReceiveMasterKey(Event),
|
||||
/// Receive announcement event
|
||||
ReceiveAnnouncement,
|
||||
/// Receive EOSE
|
||||
Eose,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Enable logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(1024);
|
||||
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100);
|
||||
|
||||
// Initialize nostr client
|
||||
let client = get_client();
|
||||
|
||||
// Initialize application
|
||||
let app = Application::new()
|
||||
.with_assets(Assets)
|
||||
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
|
||||
|
||||
// Connect to default relays
|
||||
app.background_executor()
|
||||
.spawn(async {
|
||||
// Fix crash on startup
|
||||
// TODO: why this is needed?
|
||||
_ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
for relay in BOOTSTRAP_RELAYS.into_iter() {
|
||||
_ = client.add_relay(relay).await;
|
||||
}
|
||||
|
||||
_ = client.add_discovery_relay("wss://relaydiscovery.com").await;
|
||||
_ = client.add_discovery_relay("wss://user.kindpag.es").await;
|
||||
|
||||
_ = client.connect().await
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle batch metadata
|
||||
app.background_executor()
|
||||
.spawn(async move {
|
||||
const BATCH_SIZE: usize = 20;
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
loop {
|
||||
let mut timeout = Box::pin(Timer::after(BATCH_TIMEOUT).fuse());
|
||||
|
||||
select! {
|
||||
pubkeys = batch_rx.recv().fuse() => {
|
||||
match pubkeys {
|
||||
Ok(keys) => {
|
||||
batch.extend(keys);
|
||||
if batch.len() >= BATCH_SIZE {
|
||||
handle_metadata(mem::take(&mut batch)).await;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
_ = timeout => {
|
||||
if !batch.is_empty() {
|
||||
handle_metadata(mem::take(&mut batch)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle notifications
|
||||
app.background_executor()
|
||||
.spawn(async move {
|
||||
let rng_keys = Keys::generate();
|
||||
let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
|
||||
let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
let device_id = SubscriptionId::new(DEVICE_SUB_ID);
|
||||
let mut notifications = client.notifications();
|
||||
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
if let RelayPoolNotification::Message { message, .. } = notification {
|
||||
match message {
|
||||
RelayMessage::Event {
|
||||
event,
|
||||
subscription_id,
|
||||
} => {
|
||||
match event.kind {
|
||||
Kind::GiftWrap => {
|
||||
if let Ok(gift) = handle_gift_wrap(&event).await {
|
||||
// Sign the rumor with the generated keys,
|
||||
// this event will be used for internal only,
|
||||
// and NEVER send to relays.
|
||||
if let Ok(event) = gift.rumor.sign_with_keys(&rng_keys) {
|
||||
let mut pubkeys = vec![];
|
||||
pubkeys.extend(event.tags.public_keys());
|
||||
pubkeys.push(event.pubkey);
|
||||
|
||||
// Save the event to the database, use for query directly.
|
||||
_ = client.database().save_event(&event).await;
|
||||
|
||||
// Send this event to the GPUI
|
||||
if new_id == *subscription_id {
|
||||
_ = event_tx.send(Signal::Event(event)).await;
|
||||
}
|
||||
|
||||
// Send all pubkeys to the batch
|
||||
_ = batch_tx.send(pubkeys).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::ContactList => {
|
||||
let pubkeys =
|
||||
event.tags.public_keys().copied().collect::<HashSet<_>>();
|
||||
|
||||
handle_metadata(pubkeys).await;
|
||||
}
|
||||
Kind::Custom(DEVICE_REQUEST_KIND) => {
|
||||
log::info!("Received device keys request");
|
||||
|
||||
_ = event_tx
|
||||
.send(Signal::RequestMasterKey(event.into_owned()))
|
||||
.await;
|
||||
}
|
||||
Kind::Custom(DEVICE_RESPONSE_KIND) => {
|
||||
log::info!("Received device keys approval");
|
||||
|
||||
_ = event_tx
|
||||
.send(Signal::ReceiveMasterKey(event.into_owned()))
|
||||
.await;
|
||||
}
|
||||
Kind::Custom(DEVICE_ANNOUNCEMENT_KIND) => {
|
||||
log::info!("Device Announcement received");
|
||||
|
||||
if let Ok(signer) = client.signer().await {
|
||||
if let Ok(public_key) = signer.get_public_key().await {
|
||||
if event.pubkey == public_key {
|
||||
if let Some(tag) = event
|
||||
.tags
|
||||
.find(TagKind::custom("client"))
|
||||
.and_then(|tag| tag.content())
|
||||
{
|
||||
set_device_name(tag).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(subscription_id) => {
|
||||
if all_id == *subscription_id {
|
||||
_ = event_tx.send(Signal::Eose).await;
|
||||
} else if device_id == *subscription_id {
|
||||
_ = event_tx.send(Signal::ReceiveAnnouncement).await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
app.run(move |cx| {
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
|
||||
// Register the `quit` function
|
||||
cx.on_action(quit);
|
||||
|
||||
// Register the `quit` function with CMD+Q
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
|
||||
// Set menu items
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "Coop".into(),
|
||||
items: vec![MenuItem::action("Quit", Quit)],
|
||||
}]);
|
||||
|
||||
// Set up the window options
|
||||
let opts = WindowOptions {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(APP_NAME)),
|
||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
|
||||
None,
|
||||
size(px(900.0), px(680.0)),
|
||||
cx,
|
||||
))),
|
||||
#[cfg(target_os = "linux")]
|
||||
window_background: WindowBackgroundAppearance::Transparent,
|
||||
#[cfg(target_os = "linux")]
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
kind: WindowKind::Normal,
|
||||
app_id: Some(APP_ID.to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Open a window with default options
|
||||
cx.open_window(opts, |window, cx| {
|
||||
// Automatically sync theme with system appearance
|
||||
window
|
||||
.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
// Initialize chat global state
|
||||
chats::registry::init(cx);
|
||||
|
||||
// Initialize device
|
||||
device::init(window, cx);
|
||||
|
||||
cx.new(|cx| {
|
||||
let root = Root::new(startup::init(window, cx).into(), window, cx);
|
||||
|
||||
// Spawn a task to handle events from nostr channel
|
||||
cx.spawn_in(window, |_, mut cx| async move {
|
||||
while let Ok(signal) = event_rx.recv().await {
|
||||
cx.update(|window, cx| {
|
||||
match signal {
|
||||
Signal::Eose => {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
chats.update(cx, |this, cx| this.load_chat_rooms(cx))
|
||||
}
|
||||
}
|
||||
Signal::Event(event) => {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
chats.update(cx, |this, cx| this.push_message(event, cx))
|
||||
}
|
||||
}
|
||||
Signal::ReceiveAnnouncement => {
|
||||
if let Some(device) = Device::global(cx) {
|
||||
device.update(cx, |this, cx| {
|
||||
this.setup_device(window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
Signal::ReceiveMasterKey(event) => {
|
||||
if let Some(device) = Device::global(cx) {
|
||||
device.update(cx, |this, cx| {
|
||||
this.recv_approval(event, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
Signal::RequestMasterKey(event) => {
|
||||
if let Some(device) = Device::global(cx) {
|
||||
device.update(cx, |this, cx| {
|
||||
this.recv_request(event, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
root
|
||||
})
|
||||
})
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
});
|
||||
}
|
||||
|
||||
async fn handle_gift_wrap(gift_wrap: &Event) -> Result<UnwrappedGift, anyhow::Error> {
|
||||
let client = get_client();
|
||||
|
||||
if let Some(device) = get_device_keys().await {
|
||||
// Try to unwrap with the device keys first
|
||||
match UnwrappedGift::from_gift_wrap(&device, gift_wrap).await {
|
||||
Ok(event) => Ok(event),
|
||||
Err(_) => {
|
||||
// Try to unwrap again with the user's signer
|
||||
let signer = client.signer().await?;
|
||||
let event = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?;
|
||||
Ok(event)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("Signer not found"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_metadata(buffer: HashSet<PublicKey>) {
|
||||
let client = get_client();
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.idle_timeout(Some(Duration::from_secs(2)));
|
||||
|
||||
let filter = Filter::new()
|
||||
.authors(buffer.iter().cloned())
|
||||
.limit(buffer.len() * 2)
|
||||
.kinds(vec![Kind::Metadata, Kind::Custom(DEVICE_ANNOUNCEMENT_KIND)]);
|
||||
|
||||
if let Err(e) = client.subscribe(filter, Some(opts)).await {
|
||||
log::error!("Failed to sync metadata: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn quit(_: &Quit, cx: &mut App) {
|
||||
log::info!("Gracefully quitting the application . . .");
|
||||
cx.quit();
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
use global::get_client;
|
||||
use gpui::{
|
||||
actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
|
||||
Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled,
|
||||
StyledImage, Window,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
dock_area::{dock::DockPlacement, DockArea, DockItem},
|
||||
popup_menu::PopupMenuExt,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme, Appearance, Theme},
|
||||
ContextModal, Icon, IconName, Root, Sizable, TitleBar,
|
||||
};
|
||||
|
||||
use super::{chat, contacts, onboarding, profile, relays, settings, sidebar, welcome};
|
||||
use crate::device::Device;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub enum PanelKind {
|
||||
Room(u64),
|
||||
Profile,
|
||||
Contacts,
|
||||
Settings,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct AddPanel {
|
||||
panel: PanelKind,
|
||||
position: DockPlacement,
|
||||
}
|
||||
|
||||
impl AddPanel {
|
||||
pub fn new(panel: PanelKind, position: DockPlacement) -> Self {
|
||||
Self { panel, position }
|
||||
}
|
||||
}
|
||||
|
||||
// Dock actions
|
||||
impl_internal_actions!(dock, [AddPanel]);
|
||||
|
||||
// Account actions
|
||||
actions!(account, [Logout]);
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<AppView> {
|
||||
AppView::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct AppView {
|
||||
dock: Entity<DockArea>,
|
||||
}
|
||||
|
||||
impl AppView {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
// Initialize dock layout
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||
let weak_dock = dock.downgrade();
|
||||
|
||||
// Initialize left dock
|
||||
let left_panel = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
||||
|
||||
// Initial central dock
|
||||
let center_panel = DockItem::split_with_sizes(
|
||||
Axis::Vertical,
|
||||
vec![DockItem::tabs(
|
||||
vec![Arc::new(welcome::init(window, cx))],
|
||||
None,
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
)],
|
||||
vec![None],
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
// Set default dock layout with left and central docks
|
||||
_ = weak_dock.update(cx, |view, cx| {
|
||||
view.set_left_dock(left_panel, Some(px(240.)), true, window, cx);
|
||||
view.set_center(center_panel, window, cx);
|
||||
});
|
||||
|
||||
cx.new(|_| Self { dock })
|
||||
}
|
||||
|
||||
fn render_mode_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("appearance")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.map(|this| {
|
||||
if cx.theme().appearance.is_dark() {
|
||||
this.icon(IconName::Sun)
|
||||
} else {
|
||||
this.icon(IconName::Moon)
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
if cx.theme().appearance.is_dark() {
|
||||
Theme::change(Appearance::Light, Some(window), cx);
|
||||
} else {
|
||||
Theme::change(Appearance::Dark, Some(window), cx);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_account_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("account")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.reverse()
|
||||
.icon(Icon::new(IconName::ChevronDownSmall))
|
||||
.when_some(Device::global(cx), |this, account| {
|
||||
this.when_some(account.read(cx).profile(), |this, profile| {
|
||||
this.child(
|
||||
img(profile.avatar.clone())
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
})
|
||||
})
|
||||
.popup_menu(move |this, _, _cx| {
|
||||
this.menu(
|
||||
"Profile",
|
||||
Box::new(AddPanel::new(PanelKind::Profile, DockPlacement::Right)),
|
||||
)
|
||||
.menu(
|
||||
"Contacts",
|
||||
Box::new(AddPanel::new(PanelKind::Contacts, DockPlacement::Right)),
|
||||
)
|
||||
.menu(
|
||||
"Settings",
|
||||
Box::new(AddPanel::new(PanelKind::Settings, DockPlacement::Center)),
|
||||
)
|
||||
.separator()
|
||||
.menu("Change account", Box::new(Logout))
|
||||
})
|
||||
}
|
||||
|
||||
fn render_relays_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("relays")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.icon(IconName::Relays)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.render_edit_relays(window, cx);
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_edit_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = relays::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, window, cx| {
|
||||
let is_loading = relays.read(cx).loading();
|
||||
|
||||
this.width(px(420.))
|
||||
.title("Edit your Messaging Relays")
|
||||
.child(relays.clone())
|
||||
.footer(
|
||||
div()
|
||||
.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.child(
|
||||
Button::new("update_inbox_relays_btn")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Large)
|
||||
.w_full()
|
||||
.loading(is_loading)
|
||||
.on_click(window.listener_for(&relays, |this, _, window, cx| {
|
||||
this.update(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match &action.panel {
|
||||
PanelKind::Room(id) => {
|
||||
// User must be logged in to open a room
|
||||
match chat::init(id, window, cx) {
|
||||
Ok(panel) => {
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
Err(e) => window.push_notification(e.to_string(), cx),
|
||||
}
|
||||
}
|
||||
PanelKind::Profile => {
|
||||
let panel = profile::init(window, cx);
|
||||
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
PanelKind::Contacts => {
|
||||
let panel = Arc::new(contacts::init(window, cx));
|
||||
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
PanelKind::Settings => {
|
||||
let panel = Arc::new(settings::init(window, cx));
|
||||
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn on_logout_action(&mut self, _action: &Logout, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let client = get_client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Reset nostr client
|
||||
client.reset().await
|
||||
})
|
||||
.detach();
|
||||
|
||||
Root::update(window, cx, |this, window, cx| {
|
||||
this.replace_view(onboarding::init(window, cx).into());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AppView {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.child(
|
||||
TitleBar::new()
|
||||
// Left side
|
||||
.child(div())
|
||||
// Right side
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.child(self.render_mode_btn(cx))
|
||||
.child(self.render_relays_btn(cx))
|
||||
.child(self.render_account_btn(cx)),
|
||||
),
|
||||
)
|
||||
// Dock
|
||||
.child(self.dock.clone()),
|
||||
)
|
||||
// Notifications
|
||||
.child(div().absolute().top_8().children(notification_layer))
|
||||
// Modals
|
||||
.children(modal_layer)
|
||||
// Actions
|
||||
.on_action(cx.listener(Self::on_panel_action))
|
||||
.on_action(cx.listener(Self::on_logout_action))
|
||||
}
|
||||
}
|
||||
@@ -1,721 +0,0 @@
|
||||
use anyhow::anyhow;
|
||||
use async_utility::task::spawn;
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use common::{
|
||||
last_seen::LastSeen,
|
||||
profile::NostrProfile,
|
||||
utils::{compare, nip96_upload},
|
||||
};
|
||||
use global::{constants::IMAGE_SERVICE, get_client};
|
||||
use gpui::{
|
||||
div, img, list, prelude::FluentBuilder, px, relative, svg, white, AnyElement, App, AppContext,
|
||||
Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, PathPromptOptions, Render,
|
||||
SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, WeakEntity,
|
||||
Window,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::fs;
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
input::{InputEvent, TextInput},
|
||||
notification::Notification,
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
v_flex, ContextModal, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
const ALERT: &str = "has not set up Messaging (DM) Relays, so they will NOT receive your messages.";
|
||||
const DESCRIPTION: &str =
|
||||
"This conversation is private. Only members of this chat can see each other's messages.";
|
||||
|
||||
pub fn init(
|
||||
id: &u64,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Result<Arc<Entity<Chat>>, anyhow::Error> {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
if let Some(room) = chats.read(cx).get(id, cx) {
|
||||
Ok(Arc::new(Chat::new(id, room, window, cx)))
|
||||
} else {
|
||||
Err(anyhow!("Chat room is not exist"))
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("Chat Registry is not initialized"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct ParsedMessage {
|
||||
avatar: SharedString,
|
||||
display_name: SharedString,
|
||||
created_at: SharedString,
|
||||
content: SharedString,
|
||||
}
|
||||
|
||||
impl ParsedMessage {
|
||||
pub fn new(profile: &NostrProfile, content: &str, created_at: Timestamp) -> Self {
|
||||
let content = SharedString::new(content);
|
||||
let created_at = LastSeen(created_at).human_readable();
|
||||
|
||||
Self {
|
||||
avatar: profile.avatar.clone(),
|
||||
display_name: profile.name.clone(),
|
||||
created_at,
|
||||
content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
enum Message {
|
||||
User(Box<ParsedMessage>),
|
||||
System(SharedString),
|
||||
Placeholder,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn new(message: ParsedMessage) -> Self {
|
||||
Self::User(Box::new(message))
|
||||
}
|
||||
|
||||
pub fn system(content: SharedString) -> Self {
|
||||
Self::System(content)
|
||||
}
|
||||
|
||||
pub fn placeholder() -> Self {
|
||||
Self::Placeholder
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Chat {
|
||||
// Panel
|
||||
id: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
// Chat Room
|
||||
room: WeakEntity<Room>,
|
||||
messages: Entity<Vec<Message>>,
|
||||
seens: Entity<Vec<EventId>>,
|
||||
list_state: ListState,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: Vec<Subscription>,
|
||||
// New Message
|
||||
input: Entity<TextInput>,
|
||||
// Media
|
||||
attaches: Entity<Option<Vec<Url>>>,
|
||||
is_uploading: bool,
|
||||
}
|
||||
|
||||
impl Chat {
|
||||
pub fn new(
|
||||
id: &u64,
|
||||
room: WeakEntity<Room>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let messages = cx.new(|_| vec![Message::placeholder()]);
|
||||
let seens = cx.new(|_| vec![]);
|
||||
let attaches = cx.new(|_| None);
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.appearance(false)
|
||||
.text_size(ui::Size::Small)
|
||||
.placeholder("Message...")
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let mut subscriptions = Vec::with_capacity(2);
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Self, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter = event {
|
||||
this.send_message(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
if let Some(room) = room.upgrade() {
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&room,
|
||||
window,
|
||||
move |this: &mut Self, _, event, window, cx| {
|
||||
this.push_message(&event.event, window, cx);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// Initialize list state
|
||||
// [item_count] always equal to 1 at the beginning
|
||||
let list_state = ListState::new(1, ListAlignment::Bottom, px(1024.), {
|
||||
let this = cx.entity().downgrade();
|
||||
move |ix, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.render_message(ix, window, cx).into_any_element()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
});
|
||||
|
||||
let this = Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
is_uploading: false,
|
||||
id: id.to_string().into(),
|
||||
room,
|
||||
messages,
|
||||
seens,
|
||||
list_state,
|
||||
input,
|
||||
attaches,
|
||||
subscriptions,
|
||||
};
|
||||
|
||||
// Verify messaging relays of all members
|
||||
this.verify_messaging_relays(cx);
|
||||
|
||||
// Load all messages from database
|
||||
this.load_messages(cx);
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_messaging_relays(&self, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let room = model.read(cx);
|
||||
let task = room.verify_inbox_relays(cx);
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(result) = task.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
result.into_iter().for_each(|item| {
|
||||
if !item.1 {
|
||||
if let Ok(Some(member)) =
|
||||
this.room.read_with(cx, |this, _| this.member(&item.0))
|
||||
{
|
||||
this.push_system_message(
|
||||
format!("{} {}", member.name, ALERT),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn load_messages(&self, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let room = model.read(cx);
|
||||
let task = room.load_messages(cx);
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(events) = task.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.push_messages(events, cx);
|
||||
});
|
||||
})
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn push_system_message(&self, content: String, cx: &mut Context<Self>) {
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let message = Message::system(content.into());
|
||||
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
}
|
||||
|
||||
fn push_message(&mut self, event: &Event, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Prevent duplicate messages
|
||||
if self.seens.read(cx).iter().any(|id| id == &event.id) {
|
||||
return;
|
||||
}
|
||||
// Add ID to seen list
|
||||
self.seen(event.id, cx);
|
||||
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let room = model.read(cx);
|
||||
|
||||
let profile = room
|
||||
.member(&event.pubkey)
|
||||
.unwrap_or(NostrProfile::new(event.pubkey, Metadata::default()));
|
||||
|
||||
let message = Message::new(ParsedMessage::new(
|
||||
&profile,
|
||||
&event.content,
|
||||
Timestamp::now(),
|
||||
));
|
||||
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
}
|
||||
|
||||
fn push_messages(&self, events: Events, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let room = model.read(cx);
|
||||
let pubkeys = room.public_keys();
|
||||
let old_len = self.messages.read(cx).len();
|
||||
|
||||
let (messages, new_len) = {
|
||||
let items: Vec<Message> = events
|
||||
.into_iter()
|
||||
.sorted_by_key(|ev| ev.created_at)
|
||||
.filter_map(|ev| {
|
||||
let mut other_pubkeys = ev.tags.public_keys().copied().collect::<Vec<_>>();
|
||||
other_pubkeys.push(ev.pubkey);
|
||||
|
||||
if !compare(&other_pubkeys, &pubkeys) {
|
||||
return None;
|
||||
}
|
||||
|
||||
room.members
|
||||
.iter()
|
||||
.find(|m| m.public_key == ev.pubkey)
|
||||
.map(|member| {
|
||||
Message::new(ParsedMessage::new(member, &ev.content, ev.created_at))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Used for update list state
|
||||
let new_len = items.len();
|
||||
|
||||
(items, new_len)
|
||||
};
|
||||
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
this.extend(messages);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, new_len);
|
||||
}
|
||||
|
||||
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get message
|
||||
let mut content = self.input.read(cx).text().to_string();
|
||||
|
||||
// Get all attaches and merge its with message
|
||||
if let Some(attaches) = self.attaches.read(cx).as_ref() {
|
||||
let merged = attaches
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
content = format!("{}\n{}", content, merged)
|
||||
}
|
||||
|
||||
if content.is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable input when sending message
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(true, window, cx);
|
||||
this.set_disabled(true, window, cx);
|
||||
});
|
||||
|
||||
let room = model.read(cx);
|
||||
let task = room.send_message(content, cx);
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if let Ok(msgs) = task.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
// Reset message input
|
||||
cx.update_entity(&this.input, |this, cx| {
|
||||
this.set_loading(false, window, cx);
|
||||
this.set_disabled(false, window, cx);
|
||||
this.set_text("", window, cx);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
|
||||
for item in msgs.into_iter() {
|
||||
window.push_notification(
|
||||
Notification::error(item).title("Message Failed to Send"),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
// TODO: support multiple upload
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
let path = paths.pop().unwrap();
|
||||
|
||||
if let Ok(file_data) = fs::read(path).await {
|
||||
let (tx, rx) = oneshot::channel::<Url>();
|
||||
|
||||
spawn(async move {
|
||||
let client = get_client();
|
||||
if let Ok(url) = nip96_upload(client, file_data).await {
|
||||
_ = tx.send(url);
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(url) = rx.await {
|
||||
_ = cx.update_window(window_handle, |_, _, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
// Stop loading spinner
|
||||
this.set_loading(false, cx);
|
||||
|
||||
this.attaches.update(cx, |this, cx| {
|
||||
if let Some(model) = this.as_mut() {
|
||||
model.push(url);
|
||||
} else {
|
||||
*this = Some(vec![url]);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// Stop loading spinner
|
||||
if let Some(view) = this.upgrade() {
|
||||
cx.update_entity(&view, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn remove_media(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.attaches.update(cx, |model, cx| {
|
||||
if let Some(urls) = model.as_mut() {
|
||||
let ix = urls.iter().position(|x| x == url).unwrap();
|
||||
urls.remove(ix);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_uploading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn seen(&mut self, id: EventId, cx: &mut Context<Self>) {
|
||||
self.seens.update(cx, |this, cx| {
|
||||
this.push(id);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn render_message(
|
||||
&self,
|
||||
ix: usize,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
if let Some(message) = self.messages.read(cx).get(ix) {
|
||||
div()
|
||||
.group("")
|
||||
.relative()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.map(|this| match message {
|
||||
Message::User(item) => this
|
||||
.hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE)))
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().transparent)
|
||||
.group_hover("", |this| {
|
||||
this.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
img(item.avatar.clone())
|
||||
.size_8()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
div().font_semibold().child(item.display_name.clone()),
|
||||
)
|
||||
.child(div().child(item.created_at.clone()).text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)),
|
||||
)
|
||||
.child(div().text_sm().child(item.content.clone())),
|
||||
),
|
||||
Message::System(content) => this
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().transparent)
|
||||
.group_hover("", |this| this.bg(cx.theme().danger)),
|
||||
)
|
||||
.child(
|
||||
img("brand/avatar.jpg")
|
||||
.size_8()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(content.clone()),
|
||||
Message::Placeholder => this
|
||||
.w_full()
|
||||
.h_32()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.line_height(relative(1.2))
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_8()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(DESCRIPTION),
|
||||
})
|
||||
} else {
|
||||
div()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Chat {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
self.room
|
||||
.read_with(cx, |this, _| {
|
||||
let facepill: Vec<SharedString> = this
|
||||
.members
|
||||
.iter()
|
||||
.map(|member| member.avatar.clone())
|
||||
.collect();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_row_reverse()
|
||||
.items_center()
|
||||
.justify_start()
|
||||
.children(facepill.into_iter().enumerate().rev().map(|(ix, face)| {
|
||||
div().when(ix > 0, |div| div.ml_neg_1()).child(
|
||||
img(face)
|
||||
.size_4()
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
})),
|
||||
)
|
||||
.when_some(this.name(), |this, name| this.child(name))
|
||||
.into_any()
|
||||
})
|
||||
.unwrap_or("Unnamed".into_any())
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Chat {}
|
||||
|
||||
impl Focusable for Chat {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Chat {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.child(list(self.list_state.clone()).flex_1())
|
||||
.child(
|
||||
div().flex_shrink_0().p_2().child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.when_some(self.attaches.read(cx).as_ref(), |this, attaches| {
|
||||
this.gap_1p5().children(attaches.iter().map(|url| {
|
||||
let url = url.clone();
|
||||
let path: SharedString = url.to_string().into();
|
||||
|
||||
div()
|
||||
.id(path.clone())
|
||||
.relative()
|
||||
.w_16()
|
||||
.child(
|
||||
img(format!(
|
||||
"{}/?url={}&w=128&h=128&fit=cover&n=-1",
|
||||
IMAGE_SERVICE, path
|
||||
))
|
||||
.size_16()
|
||||
.shadow_lg()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.object_fit(ObjectFit::ScaleDown),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_neg_2()
|
||||
.right_neg_2()
|
||||
.size_4()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().danger)
|
||||
.child(
|
||||
Icon::new(IconName::Close)
|
||||
.size_2()
|
||||
.text_color(white()),
|
||||
),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.remove_media(&url, window, cx);
|
||||
}))
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.h_9()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(Icon::new(IconName::Upload))
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload_media(window, cx);
|
||||
}))
|
||||
.loading(self.is_uploading),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.rounded(px(cx.theme().radius))
|
||||
.pl_2()
|
||||
.pr_1()
|
||||
.child(self.input.clone())
|
||||
.child(
|
||||
Button::new("send")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Medium)
|
||||
.label("SEND")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.send_message(window, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
use common::profile::NostrProfile;
|
||||
use global::get_client;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context,
|
||||
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, Styled, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use ui::{
|
||||
button::Button,
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
indicator::Indicator,
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Sizable,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Contacts> {
|
||||
Contacts::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Contacts {
|
||||
contacts: Entity<Option<Vec<NostrProfile>>>,
|
||||
// Panel
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Contacts {
|
||||
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let contacts = cx.new(|_| None);
|
||||
let async_contact = contacts.clone();
|
||||
|
||||
cx.spawn(|mut cx| async move {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>();
|
||||
|
||||
cx.background_executor()
|
||||
.spawn(async move {
|
||||
let signer = client.signer().await.unwrap();
|
||||
let public_key = signer.get_public_key().await.unwrap();
|
||||
|
||||
if let Ok(profiles) = client.database().contacts(public_key).await {
|
||||
let members: Vec<NostrProfile> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| {
|
||||
NostrProfile::new(profile.public_key(), profile.metadata())
|
||||
})
|
||||
.collect();
|
||||
|
||||
_ = tx.send(members);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
if let Ok(contacts) = rx.await {
|
||||
_ = cx.update_entity(&async_contact, |this, cx| {
|
||||
*this = Some(contacts);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.new(|cx| Self {
|
||||
contacts,
|
||||
name: "Contacts".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Contacts {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"ContactPanel".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Contacts {}
|
||||
|
||||
impl Focusable for Contacts {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Contacts {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().pt_2().px_2().map(|this| {
|
||||
if let Some(contacts) = self.contacts.read(cx).clone() {
|
||||
this.child(
|
||||
uniform_list(
|
||||
cx.entity().clone(),
|
||||
"contacts",
|
||||
contacts.len(),
|
||||
move |_, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let item = contacts.get(ix).unwrap().clone();
|
||||
|
||||
items.push(
|
||||
div()
|
||||
.w_full()
|
||||
.h_9()
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.child(img(item.avatar).size_6()),
|
||||
)
|
||||
.child(item.name),
|
||||
)
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.h_full(),
|
||||
)
|
||||
} else {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_16()
|
||||
.child(Indicator::new().small())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
mod chat;
|
||||
mod contacts;
|
||||
mod profile;
|
||||
mod settings;
|
||||
mod sidebar;
|
||||
mod welcome;
|
||||
|
||||
pub mod app;
|
||||
pub mod onboarding;
|
||||
pub mod relays;
|
||||
pub mod startup;
|
||||
@@ -1,399 +0,0 @@
|
||||
use common::qr::create_qr;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, relative, svg, App, AppContext, Context, Entity, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Window,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use ui::{
|
||||
button::{Button, ButtonCustomVariant, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Disableable, Size, StyledExt,
|
||||
};
|
||||
|
||||
use crate::device::Device;
|
||||
|
||||
const LOGO_URL: &str = "brand/coop.svg";
|
||||
const TITLE: &str = "Welcome to Coop!";
|
||||
const SUBTITLE: &str = "A Nostr client for secure communication.";
|
||||
// TODO: Replace it with Persona Mobile App
|
||||
const NSTART_URL: &str =
|
||||
"https://start.njump.me?an=Coop&at=ios&ac=coop&afb=yes&asf=yes&aan=null&aac=null&arr=wss://relay.damus.io&awr=wss://relay.primal.net,wss://purplerelay.com,wss://offchain.pub";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
|
||||
Onboarding::new(window, cx)
|
||||
}
|
||||
|
||||
enum PageKind {
|
||||
Bunker,
|
||||
Connect,
|
||||
Selection,
|
||||
}
|
||||
|
||||
pub struct Onboarding {
|
||||
bunker_input: Entity<TextInput>,
|
||||
connect_url: Entity<Option<PathBuf>>,
|
||||
error_message: Entity<Option<SharedString>>,
|
||||
open: PageKind,
|
||||
is_loading: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Onboarding {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let connect_url = cx.new(|_| None);
|
||||
let error_message = cx.new(|_| None);
|
||||
let bunker_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.placeholder("bunker://<pubkey>?relay=wss://relay.example.com")
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&bunker_input,
|
||||
window,
|
||||
move |this: &mut Self, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.connect(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
Self {
|
||||
bunker_input,
|
||||
connect_url,
|
||||
error_message,
|
||||
subscriptions,
|
||||
open: PageKind::Selection,
|
||||
is_loading: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn login(&self, signer: NostrConnect, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(device) = Device::global(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let entity = cx.weak_entity();
|
||||
|
||||
device.update(cx, |this, cx| {
|
||||
let login = this.login(signer, cx);
|
||||
|
||||
cx.spawn(|_, cx| async move {
|
||||
if let Err(e) = login.await {
|
||||
cx.update(|cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.set_error(e.to_string(), cx);
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
|
||||
fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(model) = Device::global(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let text = self.bunker_input.read(cx).text().to_string();
|
||||
let keys = Arc::unwrap_or_clone(model.read(cx).client_keys());
|
||||
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let Ok(uri) = NostrConnectURI::parse(text) else {
|
||||
self.set_loading(false, cx);
|
||||
self.set_error("Bunker URL is invalid".to_owned(), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(signer) = NostrConnect::new(uri, keys, Duration::from_secs(300), None) else {
|
||||
self.set_loading(false, cx);
|
||||
self.set_error("Failed to establish connection".to_owned(), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
self.login(signer, window, cx);
|
||||
}
|
||||
|
||||
fn wait_for_connection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let app_keys = Keys::generate();
|
||||
let url = NostrConnectURI::client(
|
||||
app_keys.public_key(),
|
||||
vec![RelayUrl::parse("wss://relay.nsec.app").unwrap()],
|
||||
"Coop",
|
||||
);
|
||||
|
||||
// Create QR code and save it to a app directory
|
||||
let qr_path = create_qr(url.to_string().as_str()).ok();
|
||||
|
||||
// Update QR code path
|
||||
self.connect_url.update(cx, |this, cx| {
|
||||
*this = qr_path;
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Open Connect page
|
||||
self.open(PageKind::Connect, window, cx);
|
||||
|
||||
// Wait for connection
|
||||
if let Ok(signer) = NostrConnect::new(url, app_keys, Duration::from_secs(300), None) {
|
||||
self.login(signer, window, cx);
|
||||
} else {
|
||||
self.set_loading(false, cx);
|
||||
self.set_error("Failed to establish connection".to_owned(), cx);
|
||||
self.open(PageKind::Selection, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_error(&mut self, msg: String, cx: &mut Context<Self>) {
|
||||
self.error_message.update(cx, |this, cx| {
|
||||
*this = Some(msg.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Dismiss error message after 3 seconds
|
||||
cx.spawn(|this, cx| async move {
|
||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||
|
||||
_ = cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.error_message.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn open(&mut self, kind: PageKind, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.open = kind;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Onboarding {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.relative()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_8()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.child(
|
||||
svg()
|
||||
.path(LOGO_URL)
|
||||
.size_12()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_lg()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child(TITLE),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.child(SUBTITLE),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(div().w_72().w_full().flex().flex_col().gap_2().map(|this| {
|
||||
match self.open {
|
||||
PageKind::Connect => this
|
||||
.when_some(self.connect_url.read(cx).as_ref(), |this, path| {
|
||||
this.child(
|
||||
div()
|
||||
.mb_2()
|
||||
.p_2()
|
||||
.size_72()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.rounded_lg()
|
||||
.shadow_md()
|
||||
.when(cx.theme().appearance.is_dark(), |this| {
|
||||
this.shadow_none().border_1().border_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::SIX),
|
||||
)
|
||||
})
|
||||
.bg(cx.theme().background)
|
||||
.child(img(path.as_path()).h_64()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("Scan this QR to connect"),
|
||||
)
|
||||
.child(
|
||||
Button::new("wait_for_connection")
|
||||
.label("Waiting for connection")
|
||||
.primary()
|
||||
.w_full()
|
||||
.loading(true)
|
||||
.disabled(true),
|
||||
)
|
||||
.child(
|
||||
Button::new("use_url")
|
||||
.label("Use Bunker URL")
|
||||
.custom(
|
||||
ButtonCustomVariant::new(window, cx)
|
||||
.color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::THREE),
|
||||
)
|
||||
.border(
|
||||
cx.theme().base.step(cx, ColorScaleStep::THREE),
|
||||
)
|
||||
.hover(
|
||||
cx.theme().base.step(cx, ColorScaleStep::FOUR),
|
||||
)
|
||||
.active(
|
||||
cx.theme().base.step(cx, ColorScaleStep::FIVE),
|
||||
)
|
||||
.foreground(
|
||||
cx.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::TWELVE),
|
||||
),
|
||||
)
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open(PageKind::Bunker, window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.my_2()
|
||||
.w_full()
|
||||
.h_px()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open(PageKind::Selection, window, cx);
|
||||
})),
|
||||
),
|
||||
PageKind::Bunker => this
|
||||
.child(
|
||||
div()
|
||||
.mb_2()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child("Bunker URL:")
|
||||
.child(self.bunker_input.clone())
|
||||
.when_some(
|
||||
self.error_message.read(cx).as_ref(),
|
||||
|this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.my_1()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label("Login")
|
||||
.primary()
|
||||
.w_full()
|
||||
.loading(self.is_loading)
|
||||
.disabled(self.is_loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.connect(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.my_2()
|
||||
.w_full()
|
||||
.h_px()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open(PageKind::Selection, window, cx);
|
||||
})),
|
||||
),
|
||||
PageKind::Selection => this
|
||||
.child(
|
||||
Button::new("login_connect_btn")
|
||||
.label("Login with Nostr Connect")
|
||||
.primary()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.wait_for_connection(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("join_btn")
|
||||
.label("Are you new? Join here!")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(|_, _, cx| {
|
||||
cx.open_url(NSTART_URL);
|
||||
}),
|
||||
),
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
use gpui::{
|
||||
div, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
button::Button,
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
popup_menu::PopupMenu,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Settings> {
|
||||
Settings::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Settings {
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self {
|
||||
name: "Settings".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Settings {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"SettingsPanel".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Settings {}
|
||||
|
||||
impl Focusable for Settings {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Settings {
|
||||
fn render(&mut self, _window: &mut gpui::Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child("Settings")
|
||||
}
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use compose::Compose;
|
||||
use gpui::{
|
||||
div, img, percentage, prelude::FluentBuilder, px, relative, uniform_list, AnyElement, App,
|
||||
AppContext, Context, Div, Empty, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Stateful,
|
||||
StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
popup_menu::PopupMenu,
|
||||
skeleton::Skeleton,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
use super::app::AddPanel;
|
||||
|
||||
mod compose;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
|
||||
Sidebar::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Sidebar {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
label: SharedString,
|
||||
is_collapsed: bool,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
let label = SharedString::from("Inbox");
|
||||
|
||||
Self {
|
||||
name: "Sidebar".into(),
|
||||
is_collapsed: false,
|
||||
focus_handle,
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let compose = cx.new(|cx| Compose::new(window, cx));
|
||||
|
||||
window.open_modal(cx, move |modal, window, cx| {
|
||||
let label = compose.read(cx).label(window, cx);
|
||||
let is_submitting = compose.read(cx).is_submitting();
|
||||
|
||||
modal
|
||||
.title("Direct Messages")
|
||||
.width(px(420.))
|
||||
.child(compose.clone())
|
||||
.footer(
|
||||
div()
|
||||
.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.child(
|
||||
Button::new("create_dm_btn")
|
||||
.label(label)
|
||||
.primary()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Large)
|
||||
.w_full()
|
||||
.loading(is_submitting)
|
||||
.disabled(is_submitting)
|
||||
.on_click(window.listener_for(&compose, |this, _, window, cx| {
|
||||
this.compose(window, cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_room(&self, ix: usize, room: &Entity<Room>, cx: &Context<Self>) -> Stateful<Div> {
|
||||
let room = room.read(cx);
|
||||
|
||||
div()
|
||||
.id(ix)
|
||||
.px_1()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.text_xs()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)))
|
||||
.child(div().flex_1().truncate().font_medium().map(|this| {
|
||||
if room.is_group() {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().accent.step(cx, ColorScaleStep::THREE))
|
||||
.child(Icon::new(IconName::GroupFill).size_3().text_color(
|
||||
cx.theme().accent.step(cx, ColorScaleStep::TWELVE),
|
||||
)),
|
||||
)
|
||||
.when_some(room.name(), |this, name| this.child(name))
|
||||
} else {
|
||||
this.when_some(room.first_member(), |this, member| {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
img(member.avatar.clone())
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(member.name.clone())
|
||||
})
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(room.ago()),
|
||||
)
|
||||
.on_click({
|
||||
let id = room.id;
|
||||
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.open(id, window, cx);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn render_skeleton(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
|
||||
(0..total).map(|_| {
|
||||
div()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
|
||||
.child(Skeleton::new().w_20().h_3().rounded_sm())
|
||||
})
|
||||
}
|
||||
|
||||
fn open(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.dispatch_action(
|
||||
Box::new(AddPanel::new(
|
||||
super::app::PanelKind::Room(id),
|
||||
ui::dock_area::dock::DockPlacement::Center,
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Sidebar {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"Sidebar".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Sidebar {}
|
||||
|
||||
impl Focusable for Sidebar {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Sidebar {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let entity = cx.entity();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.py_3()
|
||||
.w_full()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id("new_message")
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_1()
|
||||
.h_7()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.size_6()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
|
||||
.child(
|
||||
Icon::new(IconName::ComposeFill)
|
||||
.small()
|
||||
.text_color(cx.theme().base.darken(cx)),
|
||||
),
|
||||
)
|
||||
.child("New Message")
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
// Open compose modal
|
||||
this.render_compose(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(Empty),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id("inbox_header")
|
||||
.px_1()
|
||||
.h_7()
|
||||
.flex()
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.size_6()
|
||||
.when(self.is_collapsed, |this| {
|
||||
this.rotate(percentage(270. / 360.))
|
||||
}),
|
||||
)
|
||||
.child(self.label.clone())
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(move |view, _event, _window, cx| {
|
||||
view.is_collapsed = !view.is_collapsed;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.when(!self.is_collapsed, |this| {
|
||||
this.flex_1()
|
||||
.w_full()
|
||||
.when_some(ChatRegistry::global(cx), |this, state| {
|
||||
let is_loading = state.read(cx).is_loading();
|
||||
let len = state.read(cx).rooms().len();
|
||||
|
||||
if is_loading {
|
||||
this.children(self.render_skeleton(5))
|
||||
} else if state.read(cx).rooms().is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.px_1()
|
||||
.w_full()
|
||||
.h_20()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("No chats"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(
|
||||
cx.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.child("Recent chats will appear here."),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
uniform_list(
|
||||
entity,
|
||||
"rooms",
|
||||
len,
|
||||
move |this, range, _, cx| {
|
||||
let mut items = vec![];
|
||||
|
||||
for ix in range {
|
||||
if let Some(room) =
|
||||
state.read(cx).rooms().get(ix)
|
||||
{
|
||||
items.push(this.render_room(ix, room, cx));
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
use gpui::{
|
||||
div, svg, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Window,
|
||||
};
|
||||
use ui::theme::{scale::ColorScaleStep, ActiveTheme};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Startup> {
|
||||
Startup::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Startup {}
|
||||
|
||||
impl Startup {
|
||||
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|_| Self {})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Startup {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
}
|
||||
}
|
||||
18
crates/auto_update/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "auto_update"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
tempfile = "3.19.1"
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
350
crates/auto_update/src/lib.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
use std::{
|
||||
env::{self, consts::OS},
|
||||
ffi::OsString,
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context as _, Error};
|
||||
use global::get_client;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SemanticVersion, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::{
|
||||
fs::{self, File},
|
||||
io::AsyncWriteExt,
|
||||
process::Command,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct GlobalAutoUpdate(Entity<AutoUpdater>);
|
||||
|
||||
impl Global for GlobalAutoUpdate {}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
let env = env!("CARGO_PKG_VERSION");
|
||||
let current_version: SemanticVersion = env.parse().expect("Invalid version in Cargo.toml");
|
||||
|
||||
AutoUpdater::set_global(
|
||||
cx.new(|_| AutoUpdater {
|
||||
current_version,
|
||||
status: AutoUpdateStatus::Idle,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
struct MacOsUnmounter {
|
||||
mount_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for MacOsUnmounter {
|
||||
fn drop(&mut self) {
|
||||
let unmount_output = std::process::Command::new("hdiutil")
|
||||
.args(["detach", "-force"])
|
||||
.arg(&self.mount_path)
|
||||
.output();
|
||||
|
||||
match unmount_output {
|
||||
Ok(output) if output.status.success() => {
|
||||
log::info!("Successfully unmounted the disk image");
|
||||
}
|
||||
Ok(output) => {
|
||||
log::error!(
|
||||
"Failed to unmount disk image: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("Error while trying to unmount disk image: {:?}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AutoUpdateStatus {
|
||||
Idle,
|
||||
Checking,
|
||||
Downloading,
|
||||
Installing,
|
||||
Updated { binary_path: PathBuf },
|
||||
Errored,
|
||||
}
|
||||
|
||||
impl AutoUpdateStatus {
|
||||
pub fn is_updated(&self) -> bool {
|
||||
matches!(self, Self::Updated { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AutoUpdater {
|
||||
status: AutoUpdateStatus,
|
||||
current_version: SemanticVersion,
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalAutoUpdate>().0.clone()
|
||||
}
|
||||
|
||||
pub fn set_global(auto_updater: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAutoUpdate(auto_updater));
|
||||
}
|
||||
|
||||
pub fn current_version(&self) -> SemanticVersion {
|
||||
self.current_version
|
||||
}
|
||||
|
||||
pub fn status(&self) -> AutoUpdateStatus {
|
||||
self.status.clone()
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
||||
self.status = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn update(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
self.set_status(AutoUpdateStatus::Checking, cx);
|
||||
|
||||
// Extract the version from the identifier tag
|
||||
let ident = match event.tags.identifier() {
|
||||
Some(i) => match i.split('@').next_back() {
|
||||
Some(i) => i,
|
||||
None => return,
|
||||
},
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Convert the version string to a SemanticVersion
|
||||
let new_version: SemanticVersion = ident.parse().expect("Invalid version");
|
||||
|
||||
// Check if the new version is the same as the current version
|
||||
if self.current_version == new_version {
|
||||
self.set_status(AutoUpdateStatus::Idle, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
// Download the new version
|
||||
self.set_status(AutoUpdateStatus::Downloading, cx);
|
||||
|
||||
let task: Task<Result<(TempDir, PathBuf), Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let ids = event.tags.event_ids().copied();
|
||||
let filter = Filter::new().ids(ids).kind(Kind::FileMetadata);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
if let Some(event) = events.into_iter().find(|event| event.content == OS) {
|
||||
let tag = event.tags.find(TagKind::Url).context("url not found")?;
|
||||
let url = Url::parse(tag.content().context("invalid")?)?;
|
||||
|
||||
let temp_dir = tempfile::Builder::new().prefix("coop-update").tempdir()?;
|
||||
let filename = match OS {
|
||||
"macos" => Ok("Coop.dmg"),
|
||||
"linux" => Ok("Coop.tar.gz"),
|
||||
"windows" => Ok("CoopUpdateInstaller.exe"),
|
||||
_ => Err(anyhow!("not supported: {:?}", OS)),
|
||||
}?;
|
||||
|
||||
let downloaded_asset = temp_dir.path().join(filename);
|
||||
let mut target_file = File::create(&downloaded_asset).await?;
|
||||
|
||||
let response = reqwest::get(url).await?;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
while let Some(item) = stream.next().await {
|
||||
let chunk = item?;
|
||||
target_file.write_all(&chunk).await?;
|
||||
}
|
||||
|
||||
log::info!("downloaded update. path:{:?}", downloaded_asset);
|
||||
|
||||
Ok((temp_dir, downloaded_asset))
|
||||
} else {
|
||||
Err(anyhow!("Not found"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok((temp_dir, downloaded_asset)) = task.await {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Installing, cx);
|
||||
|
||||
match OS {
|
||||
"macos" => this.install_release_macos(temp_dir, downloaded_asset, cx),
|
||||
"linux" => this.install_release_linux(temp_dir, downloaded_asset, cx),
|
||||
"windows" => this.install_release_windows(downloaded_asset, cx),
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn install_release_macos(&mut self, temp_dir: TempDir, asset: PathBuf, cx: &mut Context<Self>) {
|
||||
let running_app_path = cx.app_path().unwrap();
|
||||
let running_app_filename = running_app_path.file_name().unwrap();
|
||||
|
||||
let mount_path = temp_dir.path().join("Coop");
|
||||
|
||||
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
|
||||
mounted_app_path.push("/");
|
||||
|
||||
let task: Task<Result<PathBuf, Error>> = cx.background_spawn(async move {
|
||||
let output = Command::new("hdiutil")
|
||||
.args(["attach", "-nobrowse"])
|
||||
.arg(&asset)
|
||||
.arg("-mountroot")
|
||||
.arg(temp_dir.path())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to mount: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
|
||||
let _unmounter = MacOsUnmounter {
|
||||
mount_path: mount_path.clone(),
|
||||
};
|
||||
|
||||
let output = Command::new("rsync")
|
||||
.args(["-av", "--delete"])
|
||||
.arg(&mounted_app_path)
|
||||
.arg(&running_app_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to copy app: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(running_app_path)
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(binary_path) = task.await {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = AutoUpdateStatus::Updated { binary_path };
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn install_release_linux(&mut self, temp_dir: TempDir, asset: PathBuf, cx: &mut Context<Self>) {
|
||||
let home_dir = PathBuf::from(env::var("HOME").unwrap());
|
||||
let running_app_path = cx.app_path().unwrap();
|
||||
let extracted = temp_dir.path().join("coop");
|
||||
|
||||
let task: Task<Result<PathBuf, Error>> = cx.background_spawn(async move {
|
||||
fs::create_dir_all(&extracted).await?;
|
||||
|
||||
let output = Command::new("tar")
|
||||
.arg("-xzf")
|
||||
.arg(&asset)
|
||||
.arg("-C")
|
||||
.arg(&extracted)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to extract {:?} to {:?}: {:?}",
|
||||
asset,
|
||||
extracted,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let app_folder_name: String = "coop.app".into();
|
||||
let from = extracted.join(&app_folder_name);
|
||||
let mut to = home_dir.join(".local");
|
||||
|
||||
let expected_suffix = format!("{}/libexec/coop", app_folder_name);
|
||||
|
||||
if let Some(prefix) = running_app_path
|
||||
.to_str()
|
||||
.and_then(|str| str.strip_suffix(&expected_suffix))
|
||||
{
|
||||
to = PathBuf::from(prefix);
|
||||
}
|
||||
|
||||
let output = Command::new("rsync")
|
||||
.args(["-av", "--delete"])
|
||||
.arg(&from)
|
||||
.arg(&to)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to copy Coop update from {:?} to {:?}: {:?}",
|
||||
from,
|
||||
to,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(to.join(expected_suffix))
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(binary_path) = task.await {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = AutoUpdateStatus::Updated { binary_path };
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn install_release_windows(&mut self, asset: PathBuf, cx: &mut Context<Self>) {
|
||||
let task: Task<Result<PathBuf, Error>> = cx.background_spawn(async move {
|
||||
let output = Command::new(asset)
|
||||
.arg("/verysilent")
|
||||
.arg("/update=true")
|
||||
.arg("!desktopicon")
|
||||
.arg("!quicklaunchicon")
|
||||
.output()
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to start installer: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Ok(std::env::current_exe()?)
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(binary_path) = task.await {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = AutoUpdateStatus::Updated { binary_path };
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
[package]
|
||||
name = "chats"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
account = { path = "../account" }
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
ui = { path = "../ui" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
|
||||
5
crates/chats/src/constants.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
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;
|
||||
@@ -1,2 +1,307 @@
|
||||
pub mod registry;
|
||||
use std::{cmp::Reverse, collections::HashMap};
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::room_hash;
|
||||
use global::get_client;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use room::RoomKind;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
|
||||
use crate::room::Room;
|
||||
|
||||
mod constants;
|
||||
pub mod message;
|
||||
pub mod room;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
|
||||
impl Global for GlobalChatRegistry {}
|
||||
|
||||
/// Main registry for managing chat rooms and user profiles
|
||||
///
|
||||
/// The ChatRegistry is responsible for:
|
||||
/// - Managing chat rooms and their states
|
||||
/// - Tracking user profiles
|
||||
/// - Loading room data from the lmdb
|
||||
/// - Handling messages and room creation
|
||||
pub struct ChatRegistry {
|
||||
/// Collection of all chat rooms
|
||||
rooms: Vec<Entity<Room>>,
|
||||
|
||||
/// Map of user public keys to their profile metadata
|
||||
profiles: Entity<HashMap<PublicKey, Option<Metadata>>>,
|
||||
|
||||
/// Indicates if rooms are currently being loaded
|
||||
loading: bool,
|
||||
|
||||
/// Subscriptions for observing changes
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl ChatRegistry {
|
||||
/// Retrieve the global ChatRegistry instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalChatRegistry>().0.clone()
|
||||
}
|
||||
|
||||
/// Set the global ChatRegistry instance
|
||||
pub fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalChatRegistry(state));
|
||||
}
|
||||
|
||||
/// Create a new ChatRegistry instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let profiles = cx.new(|_| HashMap::new());
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
// Observe new Room creations to collect profile metadata
|
||||
subscriptions.push(cx.observe_new::<Room>(|this, _, cx| {
|
||||
let task = this.metadata(cx);
|
||||
|
||||
cx.spawn(async move |_, cx| {
|
||||
if let Ok(data) = task.await {
|
||||
cx.update(|cx| {
|
||||
for (public_key, metadata) in data.into_iter() {
|
||||
Self::global(cx).update(cx, |this, cx| {
|
||||
this.add_profile(public_key, metadata, cx);
|
||||
})
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}));
|
||||
|
||||
Self {
|
||||
rooms: vec![],
|
||||
loading: true,
|
||||
profiles,
|
||||
subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the global loading status
|
||||
pub fn loading(&self) -> bool {
|
||||
self.loading
|
||||
}
|
||||
|
||||
/// Get a room by its ID.
|
||||
pub fn room(&self, id: &u64, cx: &App) -> Option<Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.find(|model| model.read(cx).id == *id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Get all rooms grouped by their kind.
|
||||
pub fn rooms(&self, cx: &App) -> HashMap<RoomKind, Vec<&Entity<Room>>> {
|
||||
let mut groups = HashMap::new();
|
||||
groups.insert(RoomKind::Ongoing, Vec::new());
|
||||
groups.insert(RoomKind::Trusted, Vec::new());
|
||||
groups.insert(RoomKind::Unknown, Vec::new());
|
||||
|
||||
for room in self.rooms.iter() {
|
||||
let kind = room.read(cx).kind;
|
||||
groups.entry(kind).or_insert_with(Vec::new).push(room);
|
||||
}
|
||||
|
||||
groups
|
||||
}
|
||||
|
||||
/// Get rooms by their kind.
|
||||
pub fn rooms_by_kind(&self, kind: RoomKind, cx: &App) -> Vec<&Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| room.read(cx).kind == kind)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the IDs of all rooms.
|
||||
pub fn room_ids(&self, cx: &mut Context<Self>) -> Vec<u64> {
|
||||
self.rooms.iter().map(|room| room.read(cx).id).collect()
|
||||
}
|
||||
|
||||
/// Load all rooms from the lmdb.
|
||||
///
|
||||
/// This method:
|
||||
/// 1. Fetches all private direct messages from the lmdb
|
||||
/// 2. Groups them by ID
|
||||
/// 3. Determines each room's type based on message frequency and trust status
|
||||
/// 4. Creates Room entities for each unique room
|
||||
pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
type Rooms = Vec<(Event, usize, bool)>;
|
||||
|
||||
let task: Task<Result<Rooms, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Get messages sent by the user
|
||||
let send = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.author(public_key);
|
||||
|
||||
// Get messages received by the user
|
||||
let recv = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.pubkey(public_key);
|
||||
|
||||
let send_events = client.database().query(send).await?;
|
||||
let recv_events = client.database().query(recv).await?;
|
||||
let events = send_events.merge(recv_events);
|
||||
|
||||
let mut room_map: HashMap<u64, (Event, usize, bool)> = HashMap::new();
|
||||
|
||||
// Process each event and group by room hash
|
||||
for event in events
|
||||
.into_iter()
|
||||
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
|
||||
{
|
||||
let hash = room_hash(&event);
|
||||
|
||||
let filter = Filter::new().kind(Kind::ContactList).pubkey(event.pubkey);
|
||||
let is_trust = client.database().count(filter).await? >= 1;
|
||||
|
||||
room_map
|
||||
.entry(hash)
|
||||
.and_modify(|(_, count, trusted)| {
|
||||
*count += 1;
|
||||
*trusted = is_trust;
|
||||
})
|
||||
.or_insert((event, 1, is_trust));
|
||||
}
|
||||
|
||||
// Sort rooms by creation date (newest first)
|
||||
let result: Vec<(Event, usize, bool)> = room_map
|
||||
.into_values()
|
||||
.sorted_by_key(|(ev, _, _)| Reverse(ev.created_at))
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(events) = task.await {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
let ids = this.room_ids(cx);
|
||||
let rooms: Vec<Entity<Room>> = events
|
||||
.into_iter()
|
||||
.filter_map(|(event, count, trusted)| {
|
||||
let hash = room_hash(&event);
|
||||
if !ids.iter().any(|this| this == &hash) {
|
||||
let kind = if count > 2 {
|
||||
// If frequency count is greater than 2, mark this room as ongoing
|
||||
RoomKind::Ongoing
|
||||
} else if trusted {
|
||||
RoomKind::Trusted
|
||||
} else {
|
||||
RoomKind::Unknown
|
||||
};
|
||||
Some(cx.new(|_| Room::new(&event).kind(kind)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
this.rooms.extend(rooms);
|
||||
this.rooms.sort_by_key(|r| Reverse(r.read(cx).created_at));
|
||||
this.loading = false;
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Add a user profile to the registry
|
||||
///
|
||||
/// Only adds the profile if it doesn't already exist or is currently none
|
||||
pub fn add_profile(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
metadata: Option<Metadata>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.profiles.update(cx, |this, _cx| {
|
||||
this.entry(public_key)
|
||||
.and_modify(|entry| {
|
||||
if entry.is_none() {
|
||||
*entry = metadata.clone();
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| metadata);
|
||||
});
|
||||
}
|
||||
|
||||
/// Get a user profile by public key
|
||||
pub fn profile(&self, public_key: &PublicKey, cx: &App) -> Profile {
|
||||
let metadata = if let Some(profile) = self.profiles.read(cx).get(public_key) {
|
||||
profile.clone().unwrap_or_default()
|
||||
} else {
|
||||
Metadata::default()
|
||||
};
|
||||
|
||||
Profile::new(*public_key, metadata)
|
||||
}
|
||||
|
||||
/// Add a new room to the registry
|
||||
///
|
||||
/// Returns an error if the room already exists
|
||||
pub fn push(&mut self, room: Room, cx: &mut Context<Self>) -> Result<(), anyhow::Error> {
|
||||
let room = cx.new(|_| room);
|
||||
|
||||
if !self
|
||||
.rooms
|
||||
.iter()
|
||||
.any(|current| current.read(cx) == room.read(cx))
|
||||
{
|
||||
self.rooms.insert(0, room);
|
||||
cx.notify();
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Room already exists"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a new message to a room
|
||||
///
|
||||
/// If the room doesn't exist, it will be created.
|
||||
/// Updates room ordering based on the most recent messages.
|
||||
pub fn push_message(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let id = room_hash(&event);
|
||||
|
||||
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.created_at(event.created_at, cx);
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.emit_message(event, window, cx);
|
||||
});
|
||||
});
|
||||
|
||||
cx.defer_in(window, |this, _, cx| {
|
||||
this.rooms
|
||||
.sort_by_key(|room| Reverse(room.read(cx).created_at));
|
||||
});
|
||||
} else {
|
||||
// Push the new room to the front of the list
|
||||
self.rooms.insert(0, cx.new(|_| Room::new(&event)));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
145
crates/chats/src/message.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use chrono::{Local, TimeZone};
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// # Message
|
||||
///
|
||||
/// Represents a message in the application.
|
||||
///
|
||||
/// ## Fields
|
||||
///
|
||||
/// - `id`: The unique identifier for the message
|
||||
/// - `content`: The text content of the message
|
||||
/// - `author`: Profile information about who created the message
|
||||
/// - `mentions`: List of profiles mentioned in the message
|
||||
/// - `created_at`: Timestamp when the message was created
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Message {
|
||||
pub id: EventId,
|
||||
pub content: String,
|
||||
pub author: Profile,
|
||||
pub mentions: Vec<Profile>,
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// Creates a new message with the provided details
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - Unique event identifier
|
||||
/// * `content` - Message text content
|
||||
/// * `author` - Profile of the message author
|
||||
/// * `created_at` - When the message was created
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new `Message` instance
|
||||
pub fn new(id: EventId, content: String, author: Profile, created_at: Timestamp) -> Self {
|
||||
Self {
|
||||
id,
|
||||
content,
|
||||
author,
|
||||
created_at,
|
||||
mentions: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds or replaces mentions in the message
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `mentions` - New list of mentioned profiles
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The same message with updated mentions
|
||||
pub fn with_mentions(mut self, mentions: impl IntoIterator<Item = Profile>) -> Self {
|
||||
self.mentions.extend(mentions);
|
||||
self
|
||||
}
|
||||
|
||||
/// Formats the message timestamp as a human-readable relative time
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A formatted string like "Today at 12:30 PM", "Yesterday at 3:45 PM",
|
||||
/// or a date and time for older messages
|
||||
pub fn ago(&self) -> SharedString {
|
||||
let input_time = match Local.timestamp_opt(self.created_at.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "Invalid timestamp".into(),
|
||||
};
|
||||
|
||||
let now = Local::now();
|
||||
let input_date = input_time.date_naive();
|
||||
let now_date = now.date_naive();
|
||||
let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
|
||||
|
||||
let time_format = input_time.format("%H:%M %p");
|
||||
|
||||
match input_date {
|
||||
date if date == now_date => format!("Today at {time_format}"),
|
||||
date if date == yesterday_date => format!("Yesterday at {time_format}"),
|
||||
_ => format!("{}, {time_format}", input_time.format("%d/%m/%y")),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// # RoomMessage
|
||||
///
|
||||
/// Represents different types of messages that can appear in a room.
|
||||
///
|
||||
/// ## Variants
|
||||
///
|
||||
/// - `User`: A message sent by a user
|
||||
/// - `System`: A message generated by the system
|
||||
/// - `Announcement`: A special message type used for room announcements
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RoomMessage {
|
||||
/// User message
|
||||
User(Box<Message>),
|
||||
/// System message
|
||||
System(SharedString),
|
||||
/// Only use for UI purposes.
|
||||
/// Placeholder will be used for display room announcement
|
||||
Announcement,
|
||||
}
|
||||
|
||||
impl RoomMessage {
|
||||
/// Creates a new user message
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `message` - The message content
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `RoomMessage::User` variant
|
||||
pub fn user(message: Message) -> Self {
|
||||
Self::User(Box::new(message))
|
||||
}
|
||||
|
||||
/// Creates a new system message
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `content` - The system message content
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `RoomMessage::System` variant
|
||||
pub fn system(content: SharedString) -> Self {
|
||||
Self::System(content)
|
||||
}
|
||||
|
||||
/// Creates a new announcement placeholder
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `RoomMessage::Announcement` variant
|
||||
pub fn announcement() -> Self {
|
||||
Self::Announcement
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use common::{last_seen::LastSeen, utils::room_hash};
|
||||
use global::get_client;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task, WeakEntity};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::room::{IncomingEvent, Room};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ChatRegistry::register(cx);
|
||||
}
|
||||
|
||||
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
|
||||
impl Global for GlobalChatRegistry {}
|
||||
|
||||
pub struct ChatRegistry {
|
||||
rooms: Vec<Entity<Room>>,
|
||||
is_loading: bool,
|
||||
}
|
||||
|
||||
impl ChatRegistry {
|
||||
pub fn global(cx: &mut App) -> Option<Entity<Self>> {
|
||||
cx.try_global::<GlobalChatRegistry>()
|
||||
.map(|global| global.0.clone())
|
||||
}
|
||||
|
||||
pub fn register(cx: &mut App) -> Entity<Self> {
|
||||
Self::global(cx).unwrap_or_else(|| {
|
||||
let entity = cx.new(Self::new);
|
||||
|
||||
// Set global state
|
||||
cx.set_global(GlobalChatRegistry(entity.clone()));
|
||||
|
||||
entity
|
||||
})
|
||||
}
|
||||
|
||||
fn new(_cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
rooms: vec![],
|
||||
is_loading: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_rooms_ids(&self, cx: &mut Context<Self>) -> Vec<u64> {
|
||||
self.rooms.iter().map(|room| room.read(cx).id).collect()
|
||||
}
|
||||
|
||||
pub fn load_chat_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let client = get_client();
|
||||
|
||||
let task: Task<Result<Vec<Event>, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let send = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.author(public_key);
|
||||
|
||||
let recv = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.pubkey(public_key);
|
||||
|
||||
let send_events = client.database().query(send).await?;
|
||||
let recv_events = client.database().query(recv).await?;
|
||||
let events = send_events.merge(recv_events);
|
||||
|
||||
let result: Vec<Event> = events
|
||||
.into_iter()
|
||||
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
|
||||
.unique_by(room_hash)
|
||||
.sorted_by_key(|ev| Reverse(ev.created_at))
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
});
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(events) = task.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
if !events.is_empty() {
|
||||
let current_ids = this.current_rooms_ids(cx);
|
||||
let items: Vec<Entity<Room>> = events
|
||||
.into_iter()
|
||||
.filter_map(|ev| {
|
||||
let new = room_hash(&ev);
|
||||
// Filter all seen rooms
|
||||
if !current_ids.iter().any(|this| this == &new) {
|
||||
Some(Room::new(&ev, cx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
this.is_loading = false;
|
||||
|
||||
this.rooms.extend(items);
|
||||
this.rooms
|
||||
.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
|
||||
} else {
|
||||
this.is_loading = false;
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn rooms(&self) -> &[Entity<Room>] {
|
||||
&self.rooms
|
||||
}
|
||||
|
||||
pub fn is_loading(&self) -> bool {
|
||||
self.is_loading
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.find(|model| model.read(cx).id == *id)
|
||||
.map(|room| room.downgrade())
|
||||
}
|
||||
|
||||
pub fn push_room(
|
||||
&mut self,
|
||||
room: Entity<Room>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
if !self
|
||||
.rooms
|
||||
.iter()
|
||||
.any(|current| current.read(cx) == room.read(cx))
|
||||
{
|
||||
self.rooms.insert(0, room);
|
||||
cx.notify();
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Room already exists"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_message(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
let id = room_hash(&event);
|
||||
|
||||
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.last_seen = LastSeen(event.created_at);
|
||||
cx.emit(IncomingEvent { event });
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Re-sort rooms by last seen
|
||||
self.rooms
|
||||
.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
|
||||
} else {
|
||||
let new_room = Room::new(&event, cx);
|
||||
|
||||
// Push the new room to the front of the list
|
||||
self.rooms.insert(0, new_room);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,51 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::{
|
||||
last_seen::LastSeen,
|
||||
profile::NostrProfile,
|
||||
utils::{device_pubkey, room_hash},
|
||||
};
|
||||
use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client, get_device_keys};
|
||||
use gpui::{App, AppContext, Entity, EventEmitter, SharedString, Task};
|
||||
use account::Account;
|
||||
use anyhow::Error;
|
||||
use chrono::{Local, TimeZone};
|
||||
use common::{compare, profile::SharedProfile, room_hash};
|
||||
use global::get_client;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::channel::Receiver;
|
||||
|
||||
use crate::{
|
||||
constants::{DAYS_IN_MONTH, HOURS_IN_DAY, MINUTES_IN_HOUR, NOW, SECONDS_IN_MINUTE},
|
||||
message::{Message, RoomMessage},
|
||||
ChatRegistry,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingEvent {
|
||||
pub event: Event,
|
||||
pub event: RoomMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SendStatus {
|
||||
Sent(EventId),
|
||||
Failed(Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, Default)]
|
||||
pub enum RoomKind {
|
||||
Ongoing,
|
||||
Trusted,
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
pub struct Room {
|
||||
pub id: u64,
|
||||
pub last_seen: LastSeen,
|
||||
pub created_at: Timestamp,
|
||||
/// Subject of the room
|
||||
pub name: Option<SharedString>,
|
||||
pub subject: Option<SharedString>,
|
||||
/// Picture of the room
|
||||
pub picture: Option<SharedString>,
|
||||
/// All members of the room
|
||||
pub members: SmallVec<[NostrProfile; 2]>,
|
||||
pub members: Arc<Vec<PublicKey>>,
|
||||
/// Kind
|
||||
pub kind: RoomKind,
|
||||
}
|
||||
|
||||
impl EventEmitter<IncomingEvent> for Room {}
|
||||
@@ -34,155 +57,362 @@ impl PartialEq for Room {
|
||||
}
|
||||
|
||||
impl Room {
|
||||
pub fn new(event: &Event, cx: &mut App) -> Entity<Self> {
|
||||
/// Creates a new Room instance from a Nostr event
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `event` - The Nostr event containing chat information
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new Room instance with information extracted from the event
|
||||
pub fn new(event: &Event) -> Self {
|
||||
let id = room_hash(event);
|
||||
let last_seen = LastSeen(event.created_at);
|
||||
let created_at = event.created_at;
|
||||
|
||||
// Get all pubkeys from the event's tags
|
||||
let mut pubkeys: Vec<PublicKey> = event.tags.public_keys().cloned().collect();
|
||||
pubkeys.push(event.pubkey);
|
||||
|
||||
// Convert pubkeys into members
|
||||
let members = Arc::new(pubkeys.into_iter().unique().sorted().collect());
|
||||
|
||||
// Get the subject from the event's tags
|
||||
let name = if let Some(tag) = event.tags.find(TagKind::Subject) {
|
||||
let subject = if let Some(tag) = event.tags.find(TagKind::Subject) {
|
||||
tag.content().map(|s| s.to_owned().into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create a task for loading metadata
|
||||
let load_metadata = Self::load_metadata(event, cx);
|
||||
// Get the picture from the event's tags
|
||||
let picture = if let Some(tag) = event.tags.find(TagKind::custom("picture")) {
|
||||
tag.content().map(|s| s.to_owned().into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let room = cx.new(|cx| {
|
||||
let this = Self {
|
||||
id,
|
||||
last_seen,
|
||||
name,
|
||||
members: smallvec![],
|
||||
};
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(profiles) = load_metadata.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this: &mut Room, cx| {
|
||||
// Update the room's name if it's not already set
|
||||
if this.name.is_none() {
|
||||
let mut name = profiles
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|profile| profile.name.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
if profiles.len() > 2 {
|
||||
name = format!("{}, +{}", name, profiles.len() - 2);
|
||||
}
|
||||
|
||||
this.name = Some(name.into())
|
||||
};
|
||||
// Update the room's members
|
||||
this.members.extend(profiles);
|
||||
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
this
|
||||
});
|
||||
|
||||
room
|
||||
Self {
|
||||
id,
|
||||
created_at,
|
||||
subject,
|
||||
picture,
|
||||
members,
|
||||
kind: RoomKind::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> u64 {
|
||||
self.id
|
||||
/// Sets the kind of the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `kind` - The kind of room to set
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The room with the updated kind
|
||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||
self.kind = kind;
|
||||
self
|
||||
}
|
||||
|
||||
/// Get room's member by public key
|
||||
pub fn member(&self, public_key: &PublicKey) -> Option<NostrProfile> {
|
||||
self.members
|
||||
/// Calculates a human-readable representation of the time passed since room creation
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A SharedString representing the relative time since room creation:
|
||||
/// - "now" for less than a minute
|
||||
/// - "Xm" for minutes
|
||||
/// - "Xh" for hours
|
||||
/// - "Xd" for days
|
||||
/// - Month and day (e.g. "Jan 15") for older dates
|
||||
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 profile for a specific public key
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `public_key` - The public key to get the profile for
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Profile associated with the given public key
|
||||
pub fn profile_by_pubkey(&self, public_key: &PublicKey, cx: &App) -> Profile {
|
||||
ChatRegistry::global(cx).read(cx).profile(public_key, cx)
|
||||
}
|
||||
|
||||
/// Gets the first member in the room that isn't the current user
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Profile of the first member in the room
|
||||
pub fn first_member(&self, cx: &App) -> Profile {
|
||||
let account = Account::global(cx).read(cx);
|
||||
let Some(profile) = account.profile.clone() else {
|
||||
return self.profile_by_pubkey(&self.members[0], cx);
|
||||
};
|
||||
|
||||
if let Some(public_key) = self
|
||||
.members
|
||||
.iter()
|
||||
.find(|m| &m.public_key == public_key)
|
||||
.cloned()
|
||||
.filter(|&pubkey| pubkey != &profile.public_key())
|
||||
.collect::<Vec<_>>()
|
||||
.first()
|
||||
{
|
||||
self.profile_by_pubkey(public_key, cx)
|
||||
} else {
|
||||
profile
|
||||
}
|
||||
}
|
||||
|
||||
/// Get room's first member's public key
|
||||
pub fn first_member(&self) -> Option<&NostrProfile> {
|
||||
self.members.first()
|
||||
/// Gets all avatars for members in the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of SharedString containing all members' avatars
|
||||
pub fn avatars(&self, cx: &App) -> Vec<SharedString> {
|
||||
let profiles: Vec<Profile> = self
|
||||
.members
|
||||
.iter()
|
||||
.map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx))
|
||||
.collect();
|
||||
|
||||
profiles
|
||||
.iter()
|
||||
.map(|member| member.shared_avatar())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Collect room's member's public keys
|
||||
pub fn public_keys(&self) -> Vec<PublicKey> {
|
||||
self.members.iter().map(|m| m.public_key).collect()
|
||||
/// Gets a formatted string of member names
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A SharedString containing formatted member names:
|
||||
/// - For a group chat: "name1, name2, +X" where X is the number of additional members
|
||||
/// - For a direct message: just the name of the other person
|
||||
pub fn names(&self, cx: &App) -> SharedString {
|
||||
if self.is_group() {
|
||||
let profiles = self
|
||||
.members
|
||||
.iter()
|
||||
.map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut name = profiles
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|profile| profile.shared_name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
if profiles.len() > 2 {
|
||||
name = format!("{}, +{}", name, profiles.len() - 2);
|
||||
}
|
||||
|
||||
name.into()
|
||||
} else {
|
||||
self.first_member(cx).shared_name()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get room's display name
|
||||
pub fn name(&self) -> Option<SharedString> {
|
||||
self.name.clone()
|
||||
/// Gets the display name for the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A SharedString representing the display name:
|
||||
/// - The subject of the room if it exists
|
||||
/// - Otherwise, the formatted names of the members
|
||||
pub fn display_name(&self, cx: &App) -> SharedString {
|
||||
if let Some(subject) = self.subject.as_ref() {
|
||||
subject.clone()
|
||||
} else {
|
||||
self.names(cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if room is a group
|
||||
/// Gets the display image for the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// An Option<SharedString> containing the avatar:
|
||||
/// - For a direct message: the other person's avatar
|
||||
/// - For a group chat: None
|
||||
pub fn display_image(&self, cx: &App) -> Option<SharedString> {
|
||||
if !self.is_group() {
|
||||
Some(self.first_member(cx).shared_avatar())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the room is a group chat
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// true if the room has more than 2 members, false otherwise
|
||||
pub fn is_group(&self) -> bool {
|
||||
self.members.len() > 2
|
||||
}
|
||||
|
||||
/// Get room's last seen
|
||||
pub fn last_seen(&self) -> LastSeen {
|
||||
self.last_seen
|
||||
/// Updates the creation timestamp of the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `created_at` - The new Timestamp to set
|
||||
/// * `cx` - The context to notify about the update
|
||||
pub fn created_at(&mut self, created_at: Timestamp, cx: &mut Context<Self>) {
|
||||
self.created_at = created_at;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Get room's last seen as ago format
|
||||
pub fn ago(&self) -> SharedString {
|
||||
self.last_seen.ago()
|
||||
/// Updates the subject of the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `subject` - The new subject to set
|
||||
/// * `cx` - The context to notify about the update
|
||||
pub fn subject(&mut self, subject: String, cx: &mut Context<Self>) {
|
||||
self.subject = Some(subject.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Sync inbox relays for all room's members
|
||||
pub fn verify_inbox_relays(&self, cx: &App) -> Task<Result<Vec<(PublicKey, bool)>, Error>> {
|
||||
/// Updates the picture of the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `picture` - The new subject to set
|
||||
/// * `cx` - The context to notify about the update
|
||||
pub fn picture(&mut self, picture: String, cx: &mut Context<Self>) {
|
||||
self.picture = Some(picture.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Fetches metadata for all members in the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The context for the background task
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A Task that resolves to Result<Vec<(PublicKey, Option<Metadata>)>, Error>
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn metadata(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Vec<(PublicKey, Option<Metadata>)>, Error>> {
|
||||
let client = get_client();
|
||||
let pubkeys = self.public_keys();
|
||||
let public_keys = self.members.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut output = vec![];
|
||||
|
||||
for public_key in public_keys.iter() {
|
||||
let metadata = client.database().metadata(*public_key).await?;
|
||||
output.push((*public_key, metadata));
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
})
|
||||
}
|
||||
|
||||
/// Checks which members have inbox relays set up
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A Task that resolves to Result<Vec<(PublicKey, bool)>, Error> where
|
||||
/// the boolean indicates if the member has inbox relays configured
|
||||
pub fn messaging_relays(&self, cx: &App) -> Task<Result<Vec<(PublicKey, bool)>, Error>> {
|
||||
let client = get_client();
|
||||
let pubkeys = Arc::clone(&self.members);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut result = Vec::with_capacity(pubkeys.len());
|
||||
|
||||
for pubkey in pubkeys.into_iter() {
|
||||
for pubkey in pubkeys.iter() {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(pubkey)
|
||||
.author(*pubkey)
|
||||
.limit(1);
|
||||
|
||||
let is_ready = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|events| events.first_owned())
|
||||
.is_some();
|
||||
let is_ready = client.database().query(filter).await?.first().is_some();
|
||||
|
||||
result.push((pubkey, is_ready));
|
||||
result.push((*pubkey, is_ready));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
/// Send message to all room's members
|
||||
/// Sends a message to all members in the room
|
||||
///
|
||||
/// NIP-4e: Message will be signed by the device signer
|
||||
pub fn send_message(&self, content: String, cx: &App) -> Task<Result<Vec<String>, Error>> {
|
||||
let client = get_client();
|
||||
let pubkeys = self.public_keys();
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `content` - The content of the message to send
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A Task that resolves to Result<Vec<String>, Error> where the
|
||||
/// strings contain error messages for any failed sends
|
||||
pub fn send_message(&self, content: String, cx: &App) -> Option<Receiver<SendStatus>> {
|
||||
let profile = Account::global(cx).read(cx).profile.clone()?;
|
||||
let public_key = profile.public_key();
|
||||
|
||||
let subject = self.subject.clone();
|
||||
let picture = self.picture.clone();
|
||||
let pubkeys = self.members.clone();
|
||||
|
||||
let (tx, rx) = smol::channel::bounded::<SendStatus>(pubkeys.len());
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let Some(device) = get_device_keys().await else {
|
||||
return Err(anyhow!("Device not found. Please restart the application."));
|
||||
};
|
||||
let client = get_client();
|
||||
|
||||
let user_signer = client.signer().await?;
|
||||
let user_pubkey = user_signer.get_public_key().await?;
|
||||
|
||||
let mut report = Vec::with_capacity(pubkeys.len());
|
||||
|
||||
let tags: Vec<Tag> = pubkeys
|
||||
let mut tags: Vec<Tag> = pubkeys
|
||||
.iter()
|
||||
.filter_map(|pubkey| {
|
||||
if pubkey != &user_pubkey {
|
||||
if pubkey != &public_key {
|
||||
Some(Tag::public_key(*pubkey))
|
||||
} else {
|
||||
None
|
||||
@@ -190,93 +420,198 @@ impl Room {
|
||||
})
|
||||
.collect();
|
||||
|
||||
for pubkey in pubkeys.iter() {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_ANNOUNCEMENT_KIND))
|
||||
.author(*pubkey)
|
||||
.limit(1);
|
||||
|
||||
// Check if the pubkey has a device announcement,
|
||||
// then choose the appropriate signer based on device presence
|
||||
let event = match client.database().query(filter).await?.first() {
|
||||
Some(event) => {
|
||||
log::info!("Use device signer to send message");
|
||||
let signer = &device;
|
||||
// Get the device's public key of other user
|
||||
let device_pubkey = device_pubkey(event)?;
|
||||
|
||||
let rumor = EventBuilder::private_msg_rumor(*pubkey, &content)
|
||||
.tags(tags.clone())
|
||||
.build(user_pubkey);
|
||||
|
||||
EventBuilder::gift_wrap(
|
||||
signer,
|
||||
&device_pubkey,
|
||||
rumor,
|
||||
vec![Tag::public_key(*pubkey)],
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
log::info!("Use user signer to send message");
|
||||
let signer = client.signer().await?;
|
||||
|
||||
EventBuilder::private_msg(&signer, *pubkey, &content, tags.clone()).await?
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = client.send_event(&event).await {
|
||||
report.push(e.to_string());
|
||||
}
|
||||
// Add subject tag if it's present
|
||||
if let Some(subject) = subject {
|
||||
tags.push(Tag::from_standardized(TagStandard::Subject(
|
||||
subject.to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
// Add picture tag if it's present
|
||||
if let Some(picture) = picture {
|
||||
tags.push(Tag::custom(TagKind::custom("picture"), vec![picture]));
|
||||
}
|
||||
|
||||
for pubkey in pubkeys.iter() {
|
||||
match client
|
||||
.send_private_msg(*pubkey, &content, tags.clone())
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
if let Err(e) = tx.send(SendStatus::Sent(output.val)).await {
|
||||
log::error!("Failed to send message to {}: {}", pubkey, e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Err(e) = tx.send(SendStatus::Failed(e.into())).await {
|
||||
log::error!("Failed to send message to {}: {}", pubkey, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
/// Load metadata for all members
|
||||
pub fn load_messages(&self, cx: &App) -> Task<Result<Events, Error>> {
|
||||
/// 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<RoomMessage>, Error>> {
|
||||
let client = get_client();
|
||||
let pubkeys = self.public_keys();
|
||||
let pubkeys = Arc::clone(&self.members);
|
||||
|
||||
let profiles: Vec<Profile> = pubkeys
|
||||
.iter()
|
||||
.map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx))
|
||||
.collect();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.authors(pubkeys.iter().copied())
|
||||
.pubkeys(pubkeys);
|
||||
.authors(pubkeys.to_vec())
|
||||
.pubkeys(pubkeys.to_vec());
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let query = client.database().query(filter).await?;
|
||||
Ok(query)
|
||||
})
|
||||
}
|
||||
let mut messages = vec![];
|
||||
let parser = NostrParser::new();
|
||||
|
||||
/// Load metadata for all members
|
||||
fn load_metadata(event: &Event, cx: &App) -> Task<Result<Vec<NostrProfile>, Error>> {
|
||||
let client = get_client();
|
||||
let mut pubkeys = vec![];
|
||||
// Get all events from database
|
||||
let events = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.sorted_by_key(|ev| ev.created_at)
|
||||
.filter(|ev| {
|
||||
let mut other_pubkeys = ev.tags.public_keys().copied().collect::<Vec<_>>();
|
||||
other_pubkeys.push(ev.pubkey);
|
||||
// Check if the event is from a member of the room
|
||||
compare(&other_pubkeys, &pubkeys)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Get all pubkeys from event's tags
|
||||
pubkeys.extend(event.tags.public_keys().collect::<HashSet<_>>());
|
||||
pubkeys.push(event.pubkey);
|
||||
for event in events.into_iter() {
|
||||
let mut mentions = vec![];
|
||||
let id = event.id;
|
||||
let created_at = event.created_at;
|
||||
let content = event.content.clone();
|
||||
let tokens = parser.parse(&content);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let signer_pubkey = signer.get_public_key().await?;
|
||||
let mut profiles = vec![];
|
||||
let author = profiles
|
||||
.iter()
|
||||
.find(|profile| profile.public_key() == event.pubkey)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Profile::new(event.pubkey, Metadata::default()));
|
||||
|
||||
for public_key in pubkeys.into_iter() {
|
||||
if let Ok(result) = client.database().metadata(public_key).await {
|
||||
let metadata = result.unwrap_or_default();
|
||||
let profile = NostrProfile::new(public_key, metadata);
|
||||
let pubkey_tokens = 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 public_key == signer_pubkey {
|
||||
profiles.push(profile);
|
||||
} else {
|
||||
profiles.insert(0, profile);
|
||||
}
|
||||
for pubkey in pubkey_tokens {
|
||||
mentions.push(
|
||||
profiles
|
||||
.iter()
|
||||
.find(|profile| profile.public_key() == pubkey)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Profile::new(pubkey, Metadata::default())),
|
||||
);
|
||||
}
|
||||
|
||||
let message = Message::new(id, content, author, created_at).with_mentions(mentions);
|
||||
let room_message = RoomMessage::user(message);
|
||||
|
||||
messages.push(room_message);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
Ok(messages)
|
||||
})
|
||||
}
|
||||
|
||||
/// Emits a message event to the GPUI
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `event` - The Nostr event to emit
|
||||
/// * `window` - The Window to emit the event to
|
||||
/// * `cx` - The context for the room
|
||||
///
|
||||
/// # Effects
|
||||
///
|
||||
/// Processes the event and emits an IncomingEvent to the UI when complete
|
||||
pub fn emit_message(&self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let pubkeys = self.members.clone();
|
||||
let profiles: Vec<Profile> = pubkeys
|
||||
.iter()
|
||||
.map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx))
|
||||
.collect();
|
||||
|
||||
let task: Task<Result<RoomMessage, Error>> = cx.background_spawn(async move {
|
||||
let parser = NostrParser::new();
|
||||
let id = event.id;
|
||||
let created_at = event.created_at;
|
||||
let content = event.content.clone();
|
||||
let tokens = parser.parse(&content);
|
||||
let mut mentions = vec![];
|
||||
|
||||
let author = profiles
|
||||
.iter()
|
||||
.find(|profile| profile.public_key() == event.pubkey)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Profile::new(event.pubkey, Metadata::default()));
|
||||
|
||||
let pubkey_tokens = 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<_>>();
|
||||
|
||||
for pubkey in pubkey_tokens {
|
||||
mentions.push(
|
||||
profiles
|
||||
.iter()
|
||||
.find(|profile| profile.public_key() == pubkey)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Profile::new(pubkey, Metadata::default())),
|
||||
);
|
||||
}
|
||||
|
||||
let message = Message::new(id, content, author, created_at).with_mentions(mentions);
|
||||
let room_message = RoomMessage::user(message);
|
||||
|
||||
Ok(room_message)
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(message) = task.await {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(IncomingEvent { event: message });
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
global = { path = "../global" }
|
||||
@@ -12,7 +12,6 @@ nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
random_name_generator = "0.3.6"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
use chrono::{Local, TimeZone};
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
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;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct LastSeen(pub Timestamp);
|
||||
|
||||
impl LastSeen {
|
||||
pub fn ago(&self) -> SharedString {
|
||||
let now = Local::now();
|
||||
let input_time = match Local.timestamp_opt(self.0.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "Invalid timestamp".into(),
|
||||
};
|
||||
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()
|
||||
}
|
||||
|
||||
pub fn human_readable(&self) -> SharedString {
|
||||
let now = Local::now();
|
||||
let input_time = match Local.timestamp_opt(self.0.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "Invalid timestamp".into(),
|
||||
};
|
||||
|
||||
let input_date = input_time.date_naive();
|
||||
let now_date = now.date_naive();
|
||||
let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
|
||||
|
||||
let time_format = input_time.format("%H:%M %p");
|
||||
|
||||
match input_date {
|
||||
date if date == now_date => format!("Today at {time_format}"),
|
||||
date if date == yesterday_date => format!("Yesterday at {time_format}"),
|
||||
_ => format!("{}, {time_format}", input_time.format("%d/%m/%y")),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn set(&mut self, created_at: Timestamp) {
|
||||
self.0 = created_at
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,78 @@
|
||||
pub mod last_seen;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use global::constants::NIP96_SERVER;
|
||||
use gpui::Image;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use qrcode_generator::QrCodeEcc;
|
||||
use rnglib::{Language, RNG};
|
||||
|
||||
pub mod profile;
|
||||
pub mod qr;
|
||||
pub mod utils;
|
||||
|
||||
pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url, anyhow::Error> {
|
||||
let signer = client.signer().await?;
|
||||
let server_url = Url::parse(NIP96_SERVER)?;
|
||||
|
||||
let config: ServerConfig = nip96::get_server_config(server_url, None).await?;
|
||||
let url = nip96::upload_data(&signer, &config, file, None, None).await?;
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub fn room_hash(event: &Event) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<&PublicKey> = vec![];
|
||||
|
||||
// Add all public keys from event
|
||||
pubkeys.push(&event.pubkey);
|
||||
pubkeys.extend(event.tags.public_keys().collect::<Vec<_>>());
|
||||
|
||||
// Generate unique hash
|
||||
pubkeys
|
||||
.into_iter()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect::<Vec<_>>()
|
||||
.hash(&mut hasher);
|
||||
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub fn device_pubkey(event: &Event) -> Result<PublicKey, anyhow::Error> {
|
||||
let n_tag = event.tags.find(TagKind::custom("n")).context("Invalid")?;
|
||||
let hex = n_tag.content().context("Invalid")?;
|
||||
let pubkey = PublicKey::parse(hex)?;
|
||||
|
||||
Ok(pubkey)
|
||||
}
|
||||
|
||||
pub fn random_name(length: usize) -> String {
|
||||
let rng = RNG::from(&Language::Roman);
|
||||
rng.generate_names(length, true).join("-").to_lowercase()
|
||||
}
|
||||
|
||||
pub fn create_qr(data: &str) -> Result<Arc<Image>, anyhow::Error> {
|
||||
let qr = qrcode_generator::to_png_to_vec_from_str(data, QrCodeEcc::Medium, 256)?;
|
||||
let img = Arc::new(Image {
|
||||
format: gpui::ImageFormat::Png,
|
||||
bytes: qr.clone(),
|
||||
id: 1,
|
||||
});
|
||||
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
pub fn compare<T>(a: &[T], b: &[T]) -> bool
|
||||
where
|
||||
T: Eq + Hash,
|
||||
{
|
||||
let a: HashSet<_> = a.iter().collect();
|
||||
let b: HashSet<_> = b.iter().collect();
|
||||
|
||||
a == b
|
||||
}
|
||||
|
||||
@@ -1,64 +1,42 @@
|
||||
use global::constants::IMAGE_SERVICE;
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NostrProfile {
|
||||
pub public_key: PublicKey,
|
||||
pub avatar: SharedString,
|
||||
pub name: SharedString,
|
||||
pub messaging_relays: Option<SmallVec<[RelayUrl; 3]>>,
|
||||
pub trait SharedProfile {
|
||||
fn shared_avatar(&self) -> SharedString;
|
||||
fn shared_name(&self) -> SharedString;
|
||||
}
|
||||
|
||||
impl NostrProfile {
|
||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||
let name = Self::extract_name(&public_key, &metadata);
|
||||
let avatar = Self::extract_avatar(&metadata);
|
||||
|
||||
Self {
|
||||
public_key,
|
||||
name,
|
||||
avatar,
|
||||
messaging_relays: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set contact's relays
|
||||
pub fn relays(mut self, relays: Option<SmallVec<[RelayUrl; 3]>>) -> Self {
|
||||
self.messaging_relays = relays;
|
||||
self
|
||||
}
|
||||
|
||||
fn extract_avatar(metadata: &Metadata) -> SharedString {
|
||||
metadata
|
||||
impl SharedProfile for Profile {
|
||||
fn shared_avatar(&self) -> SharedString {
|
||||
self.metadata()
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| {
|
||||
format!(
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1&default=npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445.blossom.band/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png",
|
||||
IMAGE_SERVICE, picture
|
||||
)
|
||||
.into()
|
||||
})
|
||||
.unwrap_or_else(|| "brand/avatar.jpg".into())
|
||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||
}
|
||||
|
||||
fn extract_name(public_key: &PublicKey, metadata: &Metadata) -> SharedString {
|
||||
if let Some(display_name) = metadata.display_name.as_ref() {
|
||||
fn shared_name(&self) -> SharedString {
|
||||
if let Some(display_name) = self.metadata().display_name.as_ref() {
|
||||
if !display_name.is_empty() {
|
||||
return display_name.into();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = metadata.name.as_ref() {
|
||||
if let Some(name) = self.metadata().name.as_ref() {
|
||||
if !name.is_empty() {
|
||||
return name.into();
|
||||
}
|
||||
}
|
||||
|
||||
let pubkey = public_key.to_hex();
|
||||
let pubkey = self.public_key().to_hex();
|
||||
|
||||
format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]).into()
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dirs::config_dir;
|
||||
use qrcode_generator::QrCodeEcc;
|
||||
|
||||
pub fn create_qr(data: &str) -> Result<PathBuf, anyhow::Error> {
|
||||
let config_dir = config_dir().expect("Config directory not found");
|
||||
let path = config_dir.join("Coop/nostr_connect.png");
|
||||
|
||||
qrcode_generator::to_png_to_file(data, QrCodeEcc::Low, 512, &path)?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
use anyhow::Context;
|
||||
use global::constants::NIP96_SERVER;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use rnglib::{Language, RNG};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
};
|
||||
|
||||
pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url, anyhow::Error> {
|
||||
let signer = client.signer().await?;
|
||||
let server_url = Url::parse(NIP96_SERVER)?;
|
||||
|
||||
let config: ServerConfig = nip96::get_server_config(server_url, None).await?;
|
||||
let url = nip96::upload_data(&signer, &config, file, None, None).await?;
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub fn room_hash(event: &Event) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<&PublicKey> = vec![];
|
||||
|
||||
// Add all public keys from event
|
||||
pubkeys.push(&event.pubkey);
|
||||
pubkeys.extend(
|
||||
event
|
||||
.tags
|
||||
.public_keys()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
// Generate unique hash
|
||||
pubkeys
|
||||
.into_iter()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect::<Vec<_>>()
|
||||
.hash(&mut hasher);
|
||||
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub fn device_pubkey(event: &Event) -> Result<PublicKey, anyhow::Error> {
|
||||
let n_tag = event.tags.find(TagKind::custom("n")).context("Invalid")?;
|
||||
let hex = n_tag.content().context("Invalid")?;
|
||||
let pubkey = PublicKey::parse(hex)?;
|
||||
|
||||
Ok(pubkey)
|
||||
}
|
||||
|
||||
pub fn random_name(length: usize) -> String {
|
||||
let rng = RNG::from(&Language::Roman);
|
||||
rng.generate_names(length, true).join("-").to_lowercase()
|
||||
}
|
||||
|
||||
pub fn compare<T>(a: &[T], b: &[T]) -> bool
|
||||
where
|
||||
T: Eq + Hash,
|
||||
{
|
||||
let a: HashSet<_> = a.iter().collect();
|
||||
let b: HashSet<_> = b.iter().collect();
|
||||
|
||||
a == b
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "coop"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "coop"
|
||||
@@ -13,12 +13,15 @@ ui = { path = "../ui" }
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
chats = { path = "../chats" }
|
||||
account = { path = "../account" }
|
||||
auto_update = { path = "../auto_update" }
|
||||
|
||||
gpui.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
nostr-connect.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -31,5 +34,5 @@ smol.workspace = true
|
||||
oneshot.workspace = true
|
||||
|
||||
rustls = "0.23.23"
|
||||
futures= "0.3"
|
||||
futures = "0.3"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
362
crates/coop/src/chatspace.rs
Normal file
@@ -0,0 +1,362 @@
|
||||
use account::Account;
|
||||
use anyhow::Error;
|
||||
use global::get_client;
|
||||
use gpui::{
|
||||
div, image_cache, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
|
||||
Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription,
|
||||
Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::{dock::DockPlacement, panel::PanelView, DockArea, DockItem},
|
||||
theme::{ActiveTheme, Appearance, Theme},
|
||||
ContextModal, IconName, Root, Sizable, TitleBar,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
lru_cache::cache_provider,
|
||||
views::{
|
||||
chat, compose, contacts, login, new_account, onboarding, profile, relays, sidebar, welcome,
|
||||
},
|
||||
};
|
||||
|
||||
const CACHE_SIZE: usize = 200;
|
||||
const MODAL_WIDTH: f32 = 420.;
|
||||
const SIDEBAR_WIDTH: f32 = 280.;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
|
||||
ChatSpace::new(window, cx)
|
||||
}
|
||||
|
||||
pub fn login(window: &mut Window, cx: &mut App) {
|
||||
let panel = login::init(window, cx);
|
||||
ChatSpace::set_center_panel(panel, window, cx);
|
||||
}
|
||||
|
||||
pub fn new_account(window: &mut Window, cx: &mut App) {
|
||||
let panel = new_account::init(window, cx);
|
||||
ChatSpace::set_center_panel(panel, window, cx);
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub enum PanelKind {
|
||||
Room(u64),
|
||||
// More kind will be added here
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub enum ModalKind {
|
||||
Profile,
|
||||
Compose,
|
||||
Contact,
|
||||
Relay,
|
||||
SetupRelay,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct ToggleModal {
|
||||
pub modal: ModalKind,
|
||||
}
|
||||
|
||||
impl_internal_actions!(dock, [AddPanel, ToggleModal]);
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct AddPanel {
|
||||
panel: PanelKind,
|
||||
position: DockPlacement,
|
||||
}
|
||||
|
||||
impl AddPanel {
|
||||
pub fn new(panel: PanelKind, position: DockPlacement) -> Self {
|
||||
Self { panel, position }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ChatSpace {
|
||||
titlebar: bool,
|
||||
dock: Entity<DockArea>,
|
||||
#[allow(unused)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl ChatSpace {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let dock = cx.new(|cx| {
|
||||
let panel = Arc::new(onboarding::init(window, cx));
|
||||
let center = DockItem::panel(panel);
|
||||
let mut dock = DockArea::new(window, cx);
|
||||
// Initialize the dock area with the center panel
|
||||
dock.set_center(center, window, cx);
|
||||
dock
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let account = Account::global(cx);
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.observe_in(
|
||||
&account,
|
||||
window,
|
||||
|this: &mut ChatSpace, account, window, cx| {
|
||||
if account.read(cx).profile.is_some() {
|
||||
this.open_chats(window, cx);
|
||||
} else {
|
||||
this.open_onboarding(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
Self {
|
||||
dock,
|
||||
subscriptions,
|
||||
titlebar: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn show_titlebar(&mut self, cx: &mut Context<Self>) {
|
||||
self.titlebar = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let panel = Arc::new(onboarding::init(window, cx));
|
||||
let center = DockItem::panel(panel);
|
||||
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn open_chats(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.show_titlebar(cx);
|
||||
|
||||
let weak_dock = self.dock.downgrade();
|
||||
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
||||
let center = DockItem::split_with_sizes(
|
||||
Axis::Vertical,
|
||||
vec![DockItem::tabs(
|
||||
vec![Arc::new(welcome::init(window, cx))],
|
||||
None,
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
)],
|
||||
vec![None],
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.set_left_dock(left, Some(px(SIDEBAR_WIDTH)), true, window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
let verify_messaging_relays = this.verify_messaging_relays(cx);
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
if let Ok(status) = verify_messaging_relays.await {
|
||||
if !status {
|
||||
cx.update(|window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(ToggleModal {
|
||||
modal: ModalKind::SetupRelay,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
|
||||
fn verify_messaging_relays(&self, cx: &App) -> Task<Result<bool, Error>> {
|
||||
cx.background_spawn(async move {
|
||||
let client = get_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 exist = client.database().query(filter).await?.first().is_some();
|
||||
|
||||
Ok(exist)
|
||||
})
|
||||
}
|
||||
|
||||
fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match &action.panel {
|
||||
PanelKind::Room(id) => {
|
||||
// User must be logged in to open a room
|
||||
match chat::init(id, window, cx) {
|
||||
Ok(panel) => {
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
Err(e) => window.push_notification(e.to_string(), cx),
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn on_modal_action(
|
||||
&mut self,
|
||||
action: &ToggleModal,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match action.modal {
|
||||
ModalKind::Profile => {
|
||||
let profile = profile::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |modal, _, _| {
|
||||
modal
|
||||
.title("Profile")
|
||||
.width(px(MODAL_WIDTH))
|
||||
.child(profile.clone())
|
||||
})
|
||||
}
|
||||
ModalKind::Compose => {
|
||||
let compose = compose::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |modal, _, _| {
|
||||
modal
|
||||
.title("Direct Messages")
|
||||
.width(px(MODAL_WIDTH))
|
||||
.child(compose.clone())
|
||||
})
|
||||
}
|
||||
ModalKind::Contact => {
|
||||
let contacts = contacts::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.width(px(MODAL_WIDTH))
|
||||
.title("Contacts")
|
||||
.child(contacts.clone())
|
||||
});
|
||||
}
|
||||
ModalKind::Relay => {
|
||||
let relays = relays::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _, _| {
|
||||
this.width(px(MODAL_WIDTH))
|
||||
.title("Edit your Messaging Relays")
|
||||
.child(relays.clone())
|
||||
});
|
||||
}
|
||||
ModalKind::SetupRelay => {
|
||||
let relays = relays::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _, _| {
|
||||
this.width(px(MODAL_WIDTH))
|
||||
.title("Your Messaging Relays are not configured")
|
||||
.child(relays.clone())
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) {
|
||||
if let Some(Some(root)) = window.root::<Root>() {
|
||||
if let Ok(chatspace) = root.read(cx).view().clone().downcast::<ChatSpace>() {
|
||||
let panel = Arc::new(panel);
|
||||
let center = DockItem::panel(panel);
|
||||
|
||||
chatspace.update(cx, |this, cx| {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ChatSpace {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
image_cache(cache_provider("image-cache", CACHE_SIZE))
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.when(self.titlebar, |this| {
|
||||
this.child(
|
||||
TitleBar::new()
|
||||
// Left side
|
||||
.child(div())
|
||||
// Right side
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.child(
|
||||
Button::new("appearance")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.map(|this| {
|
||||
if cx.theme().appearance.is_dark() {
|
||||
this.icon(IconName::Sun)
|
||||
} else {
|
||||
this.icon(IconName::Moon)
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(
|
||||
|_, _, window, cx| {
|
||||
if cx.theme().appearance.is_dark() {
|
||||
Theme::change(
|
||||
Appearance::Light,
|
||||
Some(window),
|
||||
cx,
|
||||
);
|
||||
} else {
|
||||
Theme::change(
|
||||
Appearance::Dark,
|
||||
Some(window),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
},
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
// Dock
|
||||
.child(self.dock.clone()),
|
||||
),
|
||||
)
|
||||
// Notifications
|
||||
.child(div().absolute().top_8().children(notification_layer))
|
||||
// Modals
|
||||
.children(modal_layer)
|
||||
// Actions
|
||||
.on_action(cx.listener(Self::on_panel_action))
|
||||
.on_action(cx.listener(Self::on_modal_action))
|
||||
}
|
||||
}
|
||||
117
crates/coop/src/lru_cache.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use futures::FutureExt;
|
||||
use gpui::{
|
||||
hash, AnyImageCache, App, AppContext, Asset, AssetLogger, Context, ElementId, Entity,
|
||||
ImageAssetLoader, ImageCache, ImageCacheProvider, Window,
|
||||
};
|
||||
|
||||
pub fn cache_provider(id: impl Into<ElementId>, max_items: usize) -> LruCacheProvider {
|
||||
LruCacheProvider {
|
||||
id: id.into(),
|
||||
max_items,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LruCacheProvider {
|
||||
id: ElementId,
|
||||
max_items: usize,
|
||||
}
|
||||
|
||||
impl ImageCacheProvider for LruCacheProvider {
|
||||
fn provide(&mut self, window: &mut Window, cx: &mut App) -> AnyImageCache {
|
||||
window
|
||||
.with_global_id(self.id.clone(), |global_id, window| {
|
||||
window.with_element_state::<Entity<LruCache>, _>(global_id, |lru_cache, _window| {
|
||||
let mut lru_cache =
|
||||
lru_cache.unwrap_or_else(|| cx.new(|cx| LruCache::new(self.max_items, cx)));
|
||||
if lru_cache.read(cx).max_items != self.max_items {
|
||||
lru_cache = cx.new(|cx| LruCache::new(self.max_items, cx));
|
||||
}
|
||||
(lru_cache.clone(), lru_cache)
|
||||
})
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
struct LruCache {
|
||||
max_items: usize,
|
||||
usages: Vec<u64>,
|
||||
cache: HashMap<u64, gpui::ImageCacheItem>,
|
||||
}
|
||||
|
||||
impl LruCache {
|
||||
fn new(max_items: usize, cx: &mut Context<Self>) -> Self {
|
||||
cx.on_release(|simple_cache, cx| {
|
||||
for (_, mut item) in std::mem::take(&mut simple_cache.cache) {
|
||||
if let Some(Ok(image)) = item.get() {
|
||||
cx.drop_image(image, None);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
max_items,
|
||||
usages: Vec::with_capacity(max_items),
|
||||
cache: HashMap::with_capacity(max_items),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageCache for LruCache {
|
||||
fn load(
|
||||
&mut self,
|
||||
resource: &gpui::Resource,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<Result<Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
||||
assert_eq!(self.usages.len(), self.cache.len());
|
||||
assert!(self.cache.len() <= self.max_items);
|
||||
|
||||
let hash = hash(resource);
|
||||
|
||||
if let Some(item) = self.cache.get_mut(&hash) {
|
||||
let current_ix = self
|
||||
.usages
|
||||
.iter()
|
||||
.position(|item| *item == hash)
|
||||
.expect("cache and usages must stay in sync");
|
||||
self.usages.remove(current_ix);
|
||||
self.usages.insert(0, hash);
|
||||
|
||||
return item.get();
|
||||
}
|
||||
|
||||
let fut = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
||||
let task = cx.background_executor().spawn(fut).shared();
|
||||
if self.usages.len() == self.max_items {
|
||||
let oldest = self.usages.pop().unwrap();
|
||||
let mut image = self
|
||||
.cache
|
||||
.remove(&oldest)
|
||||
.expect("cache and usages must be in sync");
|
||||
if let Some(Ok(image)) = image.get() {
|
||||
cx.drop_image(image, Some(window));
|
||||
}
|
||||
}
|
||||
self.cache
|
||||
.insert(hash, gpui::ImageCacheItem::Loading(task.clone()));
|
||||
self.usages.insert(0, hash);
|
||||
|
||||
let entity = window.current_view();
|
||||
window
|
||||
.spawn(cx, {
|
||||
async move |cx| {
|
||||
_ = task.await;
|
||||
cx.on_next_frame(move |_, cx| {
|
||||
cx.notify(entity);
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
400
crates/coop/src/main.rs
Normal file
@@ -0,0 +1,400 @@
|
||||
use anyhow::{anyhow, Error};
|
||||
use asset::Assets;
|
||||
use auto_update::AutoUpdater;
|
||||
use chats::ChatRegistry;
|
||||
use futures::{select, FutureExt};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use global::constants::APP_NAME;
|
||||
use global::{
|
||||
constants::{ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, NEW_MESSAGE_SUB_ID},
|
||||
get_client,
|
||||
};
|
||||
use gpui::{
|
||||
actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
|
||||
WindowBounds, WindowKind, WindowOptions,
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use gpui::{point, SharedString, TitlebarOptions};
|
||||
#[cfg(target_os = "linux")]
|
||||
use gpui::{WindowBackgroundAppearance, WindowDecorations};
|
||||
use nostr_sdk::{
|
||||
nips::nip01::Coordinate, pool::prelude::ReqExitPolicy, Client, Event, EventBuilder, EventId,
|
||||
Filter, JsonUtil, Keys, Kind, Metadata, PublicKey, RelayMessage, RelayPoolNotification,
|
||||
SubscribeAutoCloseOptions, SubscriptionId, Tag,
|
||||
};
|
||||
use smol::Timer;
|
||||
use std::{collections::HashSet, mem, sync::Arc, time::Duration};
|
||||
use ui::{theme::Theme, Root};
|
||||
|
||||
pub(crate) mod asset;
|
||||
pub(crate) mod chatspace;
|
||||
pub(crate) mod lru_cache;
|
||||
pub(crate) mod views;
|
||||
|
||||
actions!(coop, [Quit]);
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Signal {
|
||||
/// Receive event
|
||||
Event(Event),
|
||||
/// Receive metadata
|
||||
Metadata(Box<(PublicKey, Option<Metadata>)>),
|
||||
/// Receive eose
|
||||
Eose,
|
||||
/// Receive app updates
|
||||
AppUpdates(Event),
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Enable logging
|
||||
tracing_subscriber::fmt::init();
|
||||
// Fix crash on startup
|
||||
_ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(2048);
|
||||
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(500);
|
||||
|
||||
// Initialize nostr client
|
||||
let client = get_client();
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
// Initialize application
|
||||
let app = Application::new()
|
||||
.with_assets(Assets)
|
||||
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
|
||||
|
||||
// Connect to default relays
|
||||
app.background_executor()
|
||||
.spawn(async move {
|
||||
for relay in BOOTSTRAP_RELAYS.into_iter() {
|
||||
if let Err(e) = client.add_relay(relay).await {
|
||||
log::error!("Failed to add relay {}: {}", relay, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Establish connection to bootstrap relays
|
||||
client.connect().await;
|
||||
|
||||
log::info!("Connected to bootstrap relays");
|
||||
log::info!("Subscribing to app updates...");
|
||||
|
||||
let coordinate = Coordinate {
|
||||
kind: Kind::Custom(32267),
|
||||
public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"),
|
||||
identifier: APP_ID.into(),
|
||||
};
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ReleaseArtifactSet)
|
||||
.coordinate(&coordinate)
|
||||
.limit(1);
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to subscribe for app updates: {}", e);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle batch metadata
|
||||
app.background_executor()
|
||||
.spawn(async move {
|
||||
const BATCH_SIZE: usize = 500;
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(300);
|
||||
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
loop {
|
||||
let mut timeout = Box::pin(Timer::after(BATCH_TIMEOUT).fuse());
|
||||
|
||||
select! {
|
||||
pubkeys = batch_rx.recv().fuse() => {
|
||||
match pubkeys {
|
||||
Ok(keys) => {
|
||||
batch.extend(keys);
|
||||
if batch.len() >= BATCH_SIZE {
|
||||
sync_metadata(mem::take(&mut batch), client, opts).await;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
_ = timeout => {
|
||||
if !batch.is_empty() {
|
||||
sync_metadata(mem::take(&mut batch), client, opts).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle notifications
|
||||
app.background_executor()
|
||||
.spawn(async move {
|
||||
let rng_keys = Keys::generate();
|
||||
let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
|
||||
let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
let mut notifications = client.notifications();
|
||||
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
if let RelayPoolNotification::Message { message, .. } = notification {
|
||||
match message {
|
||||
RelayMessage::Event {
|
||||
event,
|
||||
subscription_id,
|
||||
} => {
|
||||
match event.kind {
|
||||
Kind::GiftWrap => {
|
||||
let event = match get_unwrapped(event.id).await {
|
||||
Ok(event) => event,
|
||||
Err(_) => match client.unwrap_gift_wrap(&event).await {
|
||||
Ok(unwrap) => {
|
||||
match unwrap.rumor.sign_with_keys(&rng_keys) {
|
||||
Ok(ev) => {
|
||||
set_unwrapped(event.id, &ev, &rng_keys)
|
||||
.await
|
||||
.ok();
|
||||
ev
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
},
|
||||
};
|
||||
|
||||
let mut pubkeys = vec![];
|
||||
pubkeys.extend(event.tags.public_keys());
|
||||
pubkeys.push(event.pubkey);
|
||||
|
||||
// Send all pubkeys to the batch to sync metadata
|
||||
batch_tx.send(pubkeys).await.ok();
|
||||
|
||||
// Save the event to the database, use for query directly.
|
||||
client.database().save_event(&event).await.ok();
|
||||
|
||||
// Send this event to the GPUI
|
||||
if new_id == *subscription_id {
|
||||
event_tx.send(Signal::Event(event)).await.ok();
|
||||
}
|
||||
}
|
||||
Kind::Metadata => {
|
||||
let metadata = Metadata::from_json(&event.content).ok();
|
||||
|
||||
event_tx
|
||||
.send(Signal::Metadata(Box::new((event.pubkey, metadata))))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
Kind::ContactList => {
|
||||
if let Ok(signer) = client.signer().await {
|
||||
if let Ok(public_key) = signer.get_public_key().await {
|
||||
if public_key == event.pubkey {
|
||||
let pubkeys = event
|
||||
.tags
|
||||
.public_keys()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
batch_tx.send(pubkeys).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::ReleaseArtifactSet => {
|
||||
let filter = Filter::new()
|
||||
.ids(event.tags.event_ids().copied())
|
||||
.kind(Kind::FileMetadata);
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to subscribe for file metadata: {}", e);
|
||||
} else {
|
||||
event_tx
|
||||
.send(Signal::AppUpdates(event.into_owned()))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(subscription_id) => {
|
||||
if all_id == *subscription_id {
|
||||
event_tx.send(Signal::Eose).await.ok();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
app.run(move |cx| {
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
|
||||
// Register the `quit` function
|
||||
cx.on_action(quit);
|
||||
|
||||
// Register the `quit` function with CMD+Q
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
|
||||
// Set menu items
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "Coop".into(),
|
||||
items: vec![MenuItem::action("Quit", Quit)],
|
||||
}]);
|
||||
|
||||
// Set up the window options
|
||||
let opts = WindowOptions {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(APP_NAME)),
|
||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
|
||||
None,
|
||||
size(px(920.0), px(700.0)),
|
||||
cx,
|
||||
))),
|
||||
#[cfg(target_os = "linux")]
|
||||
window_background: WindowBackgroundAppearance::Transparent,
|
||||
#[cfg(target_os = "linux")]
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
kind: WindowKind::Normal,
|
||||
app_id: Some(APP_ID.to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Open a window with default options
|
||||
cx.open_window(opts, |window, cx| {
|
||||
// Automatically sync theme with system appearance
|
||||
window
|
||||
.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Root Entity
|
||||
cx.new(|cx| {
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
// Initialize auto update
|
||||
auto_update::init(cx);
|
||||
|
||||
// Initialize chat state
|
||||
chats::init(cx);
|
||||
|
||||
// Initialize account state
|
||||
account::init(cx);
|
||||
|
||||
// Spawn a task to handle events from nostr channel
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
while let Ok(signal) = event_rx.recv().await {
|
||||
cx.update(|window, cx| {
|
||||
let chats = ChatRegistry::global(cx);
|
||||
let auto_updater = AutoUpdater::global(cx);
|
||||
|
||||
match signal {
|
||||
Signal::Event(event) => {
|
||||
chats.update(cx, |this, cx| {
|
||||
this.push_message(event, window, cx)
|
||||
});
|
||||
}
|
||||
Signal::Metadata(data) => {
|
||||
chats.update(cx, |this, cx| {
|
||||
this.add_profile(data.0, data.1, cx)
|
||||
});
|
||||
}
|
||||
Signal::Eose => {
|
||||
chats.update(cx, |this, cx| {
|
||||
// This function maybe called multiple times
|
||||
// TODO: only handle the last EOSE signal
|
||||
this.load_rooms(window, cx)
|
||||
});
|
||||
}
|
||||
Signal::AppUpdates(event) => {
|
||||
// TODO: add settings for auto updates
|
||||
auto_updater.update(cx, |this, cx| {
|
||||
this.update(event, cx);
|
||||
})
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Root::new(chatspace::init(window, cx).into(), window, cx)
|
||||
})
|
||||
})
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
});
|
||||
}
|
||||
|
||||
async fn set_unwrapped(root: EventId, event: &Event, keys: &Keys) -> Result<(), Error> {
|
||||
let client = get_client();
|
||||
let event = EventBuilder::new(Kind::Custom(9001), event.as_json())
|
||||
.tags(vec![Tag::event(root)])
|
||||
.sign(keys)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_unwrapped(gift_wrap: EventId) -> Result<Event, Error> {
|
||||
let client = get_client();
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(9001))
|
||||
.event(gift_wrap)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let parsed = Event::from_json(event.content)?;
|
||||
Ok(parsed)
|
||||
} else {
|
||||
Err(anyhow!("Event not found"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_metadata(
|
||||
buffer: HashSet<PublicKey>,
|
||||
client: &Client,
|
||||
opts: SubscribeAutoCloseOptions,
|
||||
) {
|
||||
let kinds = vec![
|
||||
Kind::Metadata,
|
||||
Kind::ContactList,
|
||||
Kind::InboxRelays,
|
||||
Kind::UserStatus,
|
||||
];
|
||||
|
||||
let filter = Filter::new()
|
||||
.authors(buffer.iter().cloned())
|
||||
.limit(buffer.len() * kinds.len())
|
||||
.kinds(kinds);
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to sync metadata: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn quit(_: &Quit, cx: &mut App) {
|
||||
log::info!("Gracefully quitting the application . . .");
|
||||
cx.quit();
|
||||
}
|
||||
629
crates/coop/src/views/chat.rs
Normal file
@@ -0,0 +1,629 @@
|
||||
use anyhow::{anyhow, Error};
|
||||
use async_utility::task::spawn;
|
||||
use chats::{
|
||||
message::RoomMessage,
|
||||
room::{Room, SendStatus},
|
||||
ChatRegistry,
|
||||
};
|
||||
use common::{nip96_upload, profile::SharedProfile};
|
||||
use global::{constants::IMAGE_SERVICE, get_client};
|
||||
use gpui::{
|
||||
div, img, impl_internal_actions, list, prelude::FluentBuilder, px, relative, svg, white,
|
||||
AnyElement, App, AppContext, Context, Element, Empty, Entity, EventEmitter, Flatten,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit,
|
||||
ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled,
|
||||
StyledImage, Subscription, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::fs;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
emoji_picker::EmojiPicker,
|
||||
input::{InputEvent, TextInput},
|
||||
notification::Notification,
|
||||
popup_menu::PopupMenu,
|
||||
text::RichText,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
v_flex, ContextModal, Disableable, Icon, IconName, Size, StyledExt,
|
||||
};
|
||||
|
||||
use crate::views::subject;
|
||||
|
||||
const ALERT: &str = "has not set up Messaging (DM) Relays, so they will NOT receive your messages.";
|
||||
const DESC: &str = "This conversation is private. Only members can see each other's messages.";
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct ChangeSubject(pub String);
|
||||
|
||||
impl_internal_actions!(chat, [ChangeSubject]);
|
||||
|
||||
pub fn init(id: &u64, window: &mut Window, cx: &mut App) -> Result<Arc<Entity<Chat>>, Error> {
|
||||
if let Some(room) = ChatRegistry::global(cx).read(cx).room(id, cx) {
|
||||
Ok(Arc::new(Chat::new(id, room, window, cx)))
|
||||
} else {
|
||||
Err(anyhow!("Chat Room not found."))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Chat {
|
||||
// Panel
|
||||
id: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
// Chat Room
|
||||
room: Entity<Room>,
|
||||
messages: Entity<Vec<RoomMessage>>,
|
||||
text_data: HashMap<EventId, RichText>,
|
||||
list_state: ListState,
|
||||
// New Message
|
||||
input: Entity<TextInput>,
|
||||
// Media Attachment
|
||||
attaches: Entity<Option<Vec<Url>>>,
|
||||
is_uploading: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl Chat {
|
||||
pub fn new(id: &u64, room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let messages = cx.new(|_| vec![RoomMessage::announcement()]);
|
||||
let attaches = cx.new(|_| None);
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("Message...")
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Self, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter = event {
|
||||
this.send_message(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&room,
|
||||
window,
|
||||
move |this, _, event, _window, cx| {
|
||||
let old_len = this.messages.read(cx).len();
|
||||
let message = event.event.clone();
|
||||
|
||||
cx.update_entity(&this.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
this.list_state.splice(old_len..old_len, 1);
|
||||
},
|
||||
));
|
||||
|
||||
// Initialize list state
|
||||
// [item_count] always equal to 1 at the beginning
|
||||
let list_state = ListState::new(1, ListAlignment::Bottom, px(1024.), {
|
||||
let this = cx.entity().downgrade();
|
||||
move |ix, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.render_message(ix, window, cx).into_any_element()
|
||||
})
|
||||
.unwrap_or(Empty.into_any())
|
||||
}
|
||||
});
|
||||
|
||||
let this = Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
is_uploading: false,
|
||||
id: id.to_string().into(),
|
||||
text_data: HashMap::new(),
|
||||
room,
|
||||
messages,
|
||||
list_state,
|
||||
input,
|
||||
attaches,
|
||||
subscriptions,
|
||||
};
|
||||
|
||||
// Verify messaging relays of all members
|
||||
this.verify_messaging_relays(window, cx);
|
||||
|
||||
// Load all messages from database
|
||||
this.load_messages(window, cx);
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_messaging_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let room = self.room.read(cx);
|
||||
let task = room.messaging_relays(cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(result) = task.await {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
result.into_iter().for_each(|item| {
|
||||
if !item.1 {
|
||||
let profile = this
|
||||
.room
|
||||
.read_with(cx, |this, _| this.profile_by_pubkey(&item.0, cx));
|
||||
|
||||
this.push_system_message(
|
||||
format!("{} {}", profile.shared_name(), ALERT),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn load_messages(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let room = self.room.read(cx);
|
||||
let task = room.load_messages(cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(events) = task.await {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
let old_len = this.messages.read(cx).len();
|
||||
let new_len = events.len();
|
||||
|
||||
// Extend the messages list with the new events
|
||||
this.messages.update(cx, |this, cx| {
|
||||
this.extend(events);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Update list state with the new messages
|
||||
this.list_state.splice(old_len..old_len, new_len);
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn push_system_message(&self, content: String, cx: &mut Context<Self>) {
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let message = RoomMessage::system(content.into());
|
||||
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
}
|
||||
|
||||
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let mut content = self.input.read(cx).text().to_string();
|
||||
|
||||
// Get all attaches and merge its with message
|
||||
if let Some(attaches) = self.attaches.read(cx).as_ref() {
|
||||
let merged = attaches
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
content = format!("{}\n{}", content, merged)
|
||||
}
|
||||
|
||||
// Check if content is empty
|
||||
if content.is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update input state
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(true, window, cx);
|
||||
this.set_disabled(true, window, cx);
|
||||
});
|
||||
|
||||
let room = self.room.read(cx);
|
||||
let task = room.send_message(content, cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let mut received = false;
|
||||
|
||||
match task {
|
||||
Some(rx) => {
|
||||
while let Ok(message) = rx.recv().await {
|
||||
if let SendStatus::Failed(error) = message {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string())
|
||||
.title("Message Failed to Send"),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
} else if !received {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.input.update(cx, |this, cx| {
|
||||
this.set_loading(false, window, cx);
|
||||
this.set_disabled(false, window, cx);
|
||||
this.set_text("", window, cx);
|
||||
});
|
||||
received = true;
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error("User is not logged in"), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
let Some(path) = paths.pop() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(file_data) = fs::read(path).await {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Url>();
|
||||
|
||||
// spawn task via async_utility
|
||||
spawn(async move {
|
||||
if let Ok(url) = nip96_upload(client, file_data).await {
|
||||
_ = tx.send(url);
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(url) = rx.await {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
|
||||
this.attaches.update(cx, |this, cx| {
|
||||
if let Some(model) = this.as_mut() {
|
||||
model.push(url);
|
||||
} else {
|
||||
*this = Some(vec![url]);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn remove_media(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.attaches.update(cx, |model, cx| {
|
||||
if let Some(urls) = model.as_mut() {
|
||||
if let Some(ix) = urls.iter().position(|x| x == url) {
|
||||
urls.remove(ix);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_uploading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_message(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let Some(message) = self.messages.read(cx).get(ix) else {
|
||||
return div().into_element();
|
||||
};
|
||||
|
||||
let text_data = &mut self.text_data;
|
||||
|
||||
div()
|
||||
.group("")
|
||||
.w_full()
|
||||
.relative()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.map(|this| match message {
|
||||
RoomMessage::User(item) => {
|
||||
let text = text_data
|
||||
.entry(item.id)
|
||||
.or_insert_with(|| RichText::new(item.content.to_owned(), &item.mentions));
|
||||
|
||||
this.hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE)))
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().transparent)
|
||||
.group_hover("", |this| {
|
||||
this.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
|
||||
}),
|
||||
)
|
||||
.child(img(item.author.shared_avatar()).size_8().flex_shrink_0())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().font_semibold().child(item.author.shared_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::NINE),
|
||||
)
|
||||
.child(item.ago()),
|
||||
),
|
||||
)
|
||||
.child(text.element("body".into(), window, cx)),
|
||||
)
|
||||
}
|
||||
RoomMessage::System(content) => this
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().transparent)
|
||||
.group_hover("", |this| this.bg(cx.theme().danger)),
|
||||
)
|
||||
.child(img("brand/avatar.png").size_8().flex_shrink_0())
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(content.clone()),
|
||||
RoomMessage::Announcement => this
|
||||
.w_full()
|
||||
.h_32()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::NINE))
|
||||
.line_height(relative(1.3))
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_10()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(DESC),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Chat {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
self.room.read_with(cx, |this, _| {
|
||||
let facepill: Vec<SharedString> = this.avatars(cx);
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_row_reverse()
|
||||
.items_center()
|
||||
.justify_start()
|
||||
.children(
|
||||
facepill
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.map(|(ix, facepill)| {
|
||||
div()
|
||||
.when(ix > 0, |div| div.ml_neg_1())
|
||||
.child(img(facepill).size_5())
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(this.display_name(cx))
|
||||
.into_any()
|
||||
})
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, cx: &App) -> Vec<Button> {
|
||||
let id = self.room.read(cx).id;
|
||||
let subject = self
|
||||
.room
|
||||
.read(cx)
|
||||
.subject
|
||||
.as_ref()
|
||||
.map(|subject| subject.to_string());
|
||||
|
||||
let button = Button::new("subject")
|
||||
.icon(IconName::EditFill)
|
||||
.tooltip("Change Subject")
|
||||
.on_click(move |_, window, cx| {
|
||||
let subject = subject::init(id, subject.clone(), window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.title("Change the subject of the conversation")
|
||||
.child(subject.clone())
|
||||
});
|
||||
});
|
||||
|
||||
vec![button]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Chat {}
|
||||
|
||||
impl Focusable for Chat {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Chat {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.child(list(self.list_state.clone()).flex_1())
|
||||
.child(
|
||||
div().flex_shrink_0().px_3().py_2().child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.when_some(self.attaches.read(cx).as_ref(), |this, attaches| {
|
||||
this.gap_1p5().children(attaches.iter().map(|url| {
|
||||
let url = url.clone();
|
||||
let path: SharedString = url.to_string().into();
|
||||
|
||||
div()
|
||||
.id(path.clone())
|
||||
.relative()
|
||||
.w_16()
|
||||
.child(
|
||||
img(format!(
|
||||
"{}/?url={}&w=128&h=128&fit=cover&n=-1",
|
||||
IMAGE_SERVICE, path
|
||||
))
|
||||
.size_16()
|
||||
.shadow_lg()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.object_fit(ObjectFit::ScaleDown),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_neg_2()
|
||||
.right_neg_2()
|
||||
.size_4()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().danger)
|
||||
.child(
|
||||
Icon::new(IconName::Close)
|
||||
.size_2()
|
||||
.text_color(white()),
|
||||
),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.remove_media(&url, window, cx);
|
||||
}))
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2p5()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(Icon::new(IconName::Upload))
|
||||
.ghost()
|
||||
.disabled(self.is_uploading)
|
||||
.loading(self.is_uploading)
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.upload_media(window, cx);
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
EmojiPicker::new(self.input.downgrade())
|
||||
.icon(IconName::EmojiFill),
|
||||
),
|
||||
)
|
||||
.child(self.input.clone()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use common::{profile::NostrProfile, utils::random_name};
|
||||
use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client};
|
||||
use anyhow::Error;
|
||||
use chats::{
|
||||
room::{Room, RoomKind},
|
||||
ChatRegistry,
|
||||
};
|
||||
use common::{profile::SharedProfile, random_name};
|
||||
use global::get_client;
|
||||
use gpui::{
|
||||
div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App,
|
||||
AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
|
||||
@@ -11,16 +15,20 @@ use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::Timer;
|
||||
use std::{collections::HashSet, time::Duration};
|
||||
use std::{
|
||||
collections::{BTreeSet, HashSet},
|
||||
time::Duration,
|
||||
};
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded},
|
||||
button::{Button, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Icon, IconName, Sizable, Size, StyledExt,
|
||||
ContextModal, Disableable, Icon, IconName, Sizable, Size, StyledExt,
|
||||
};
|
||||
|
||||
const DESCRIPTION: &str =
|
||||
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).";
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Compose> {
|
||||
cx.new(|cx| Compose::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
struct SelectContact(PublicKey);
|
||||
@@ -30,7 +38,7 @@ impl_internal_actions!(contacts, [SelectContact]);
|
||||
pub struct Compose {
|
||||
title_input: Entity<TextInput>,
|
||||
user_input: Entity<TextInput>,
|
||||
contacts: Entity<Vec<NostrProfile>>,
|
||||
contacts: Entity<Vec<Profile>>,
|
||||
selected: Entity<HashSet<PublicKey>>,
|
||||
focus_handle: FocusHandle,
|
||||
is_loading: bool,
|
||||
@@ -50,7 +58,7 @@ impl Compose {
|
||||
let name = random_name(2);
|
||||
let mut input = TextInput::new(window, cx)
|
||||
.appearance(false)
|
||||
.text_size(Size::XSmall);
|
||||
.text_size(Size::Small);
|
||||
|
||||
input.set_placeholder("Family... . (Optional)");
|
||||
input.set_text(name, window, cx);
|
||||
@@ -77,34 +85,27 @@ impl Compose {
|
||||
},
|
||||
));
|
||||
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let task: Task<Result<BTreeSet<Profile>, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let profiles = client.database().contacts(public_key).await?;
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await.unwrap();
|
||||
let public_key = signer.get_public_key().await.unwrap();
|
||||
Ok(profiles)
|
||||
});
|
||||
|
||||
if let Ok(profiles) = client.database().contacts(public_key).await {
|
||||
let members: Vec<NostrProfile> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| NostrProfile::new(profile.public_key(), profile.metadata()))
|
||||
.collect();
|
||||
|
||||
_ = tx.send(members);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(contacts) = rx.await {
|
||||
_ = cx.update(|cx| {
|
||||
if let Ok(contacts) = task.await {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.extend(contacts);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
});
|
||||
.ok()
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
@@ -146,14 +147,13 @@ impl Compose {
|
||||
}
|
||||
|
||||
let tags = Tags::from_list(tag_list);
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
let event: Task<Result<Event, anyhow::Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
// [IMPORTANT]
|
||||
// Make sure this event is never send,
|
||||
// this event existed just use for convert to Coop's Chat Room later.
|
||||
// this event existed just use for convert to Coop's Room later.
|
||||
let event = EventBuilder::private_msg_rumor(*pubkeys.last().unwrap(), "")
|
||||
.tags(tags)
|
||||
.sign(&signer)
|
||||
@@ -162,21 +162,19 @@ impl Compose {
|
||||
Ok(event)
|
||||
});
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(event) = event.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
// Stop loading spinner
|
||||
_ = this.update(cx, |this, cx| {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_submitting(false, cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
|
||||
let Some(chats) = ChatRegistry::global(cx) else {
|
||||
return;
|
||||
};
|
||||
let room = Room::new(&event, cx);
|
||||
let chats = ChatRegistry::global(cx);
|
||||
let room = Room::new(&event).kind(RoomKind::Ongoing);
|
||||
|
||||
chats.update(cx, |state, cx| {
|
||||
match state.push_room(room, cx) {
|
||||
chats.update(cx, |chats, cx| {
|
||||
match chats.push(room, cx) {
|
||||
Ok(_) => {
|
||||
// TODO: automatically open newly created chat panel
|
||||
window.close_modal(cx);
|
||||
@@ -188,33 +186,21 @@ impl Compose {
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn label(&self, _window: &Window, cx: &App) -> SharedString {
|
||||
if self.selected.read(cx).len() > 1 {
|
||||
"Create Group DM".into()
|
||||
} else {
|
||||
"Create DM".into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_submitting(&self) -> bool {
|
||||
self.is_submitting
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let client = get_client();
|
||||
let window_handle = window.window_handle();
|
||||
let content = self.user_input.read(cx).text().to_string();
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let task: Task<Result<NostrProfile, anyhow::Error>> = if content.contains("@") {
|
||||
let task: Task<Result<Profile, anyhow::Error>> = if content.contains("@") {
|
||||
cx.background_spawn(async move {
|
||||
let profile = nip05::profile(&content, None).await?;
|
||||
let public_key = profile.public_key;
|
||||
@@ -224,7 +210,7 @@ impl Compose {
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(NostrProfile::new(public_key, metadata))
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
})
|
||||
} else {
|
||||
let Ok(public_key) = PublicKey::parse(&content) else {
|
||||
@@ -239,34 +225,16 @@ impl Compose {
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(NostrProfile::new(public_key, metadata))
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
})
|
||||
};
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(profile) => {
|
||||
let public_key = profile.public_key;
|
||||
|
||||
_ = cx
|
||||
.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
// Create a device announcement filter
|
||||
let device = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_ANNOUNCEMENT_KIND))
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Only subscribe to the latest device announcement
|
||||
client.subscribe(device, Some(opts)).await
|
||||
})
|
||||
.await;
|
||||
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
let public_key = profile.public_key;
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
let public_key = profile.public_key();
|
||||
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.insert(0, profile);
|
||||
@@ -286,16 +254,20 @@ impl Compose {
|
||||
this.set_text("", window, cx);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
_ = cx.update_window(window_handle, |_, _, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
});
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -309,14 +281,16 @@ impl Compose {
|
||||
});
|
||||
|
||||
// Dismiss error after 2 seconds
|
||||
cx.spawn(|this, cx| async move {
|
||||
cx.spawn(async move |this, cx| {
|
||||
Timer::after(Duration::from_secs(2)).await;
|
||||
|
||||
_ = cx.update(|cx| {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(None, cx);
|
||||
})
|
||||
});
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -350,6 +324,15 @@ impl Compose {
|
||||
|
||||
impl Render for Compose {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const DESCRIPTION: &str =
|
||||
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).";
|
||||
|
||||
let label: SharedString = if self.selected.read(cx).len() > 1 {
|
||||
"Create Group DM".into()
|
||||
} else {
|
||||
"Create DM".into()
|
||||
};
|
||||
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::on_action_select))
|
||||
@@ -358,15 +341,13 @@ impl Render for Compose {
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(DESCRIPTION),
|
||||
)
|
||||
.when_some(self.error_message.read(cx).as_ref(), |this, msg| {
|
||||
this.child(
|
||||
div()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(msg.clone()),
|
||||
@@ -376,13 +357,12 @@ impl Render for Compose {
|
||||
div().flex().flex_col().child(
|
||||
div()
|
||||
.h_10()
|
||||
.px_2()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(div().text_xs().font_semibold().child("Title:"))
|
||||
.child(div().pb_0p5().text_sm().font_semibold().child("Title:"))
|
||||
.child(self.title_input.clone()),
|
||||
),
|
||||
)
|
||||
@@ -391,25 +371,9 @@ impl Render for Compose {
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(div().px_2().text_xs().font_semibold().child("To:"))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.child(
|
||||
Button::new("add_user_to_compose_btn")
|
||||
.icon(IconName::Plus)
|
||||
.small()
|
||||
.rounded(ButtonRounded::Size(px(9999.)))
|
||||
.loading(self.is_loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.add(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(self.user_input.clone()),
|
||||
)
|
||||
.mt_1()
|
||||
.child(div().text_sm().font_semibold().child("To:"))
|
||||
.child(self.user_input.clone())
|
||||
.map(|this| {
|
||||
let contacts = self.contacts.read(cx).clone();
|
||||
let view = cx.entity();
|
||||
@@ -452,38 +416,41 @@ impl Render for Compose {
|
||||
|
||||
for ix in range {
|
||||
let item = contacts.get(ix).unwrap().clone();
|
||||
let is_select = selected.contains(&item.public_key);
|
||||
let is_select = selected.contains(&item.public_key());
|
||||
|
||||
items.push(
|
||||
div()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.h_9()
|
||||
.h_10()
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.gap_3()
|
||||
.text_sm()
|
||||
.child(
|
||||
div().flex_shrink_0().child(
|
||||
img(item.avatar).size_6(),
|
||||
),
|
||||
img(item.shared_avatar())
|
||||
.size_7()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(item.name),
|
||||
.child(item.shared_name()),
|
||||
)
|
||||
.when(is_select, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::CircleCheck)
|
||||
.size_3()
|
||||
.text_color(cx.theme().base.step(
|
||||
cx,
|
||||
ColorScaleStep::TWELVE,
|
||||
)),
|
||||
Icon::new(IconName::CheckCircleFill)
|
||||
.small()
|
||||
.text_color(
|
||||
cx.theme().accent.step(
|
||||
cx,
|
||||
ColorScaleStep::NINE,
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.hover(|this| {
|
||||
@@ -495,7 +462,7 @@ impl Render for Compose {
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(SelectContact(
|
||||
item.public_key,
|
||||
item.public_key(),
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
@@ -506,10 +473,22 @@ impl Render for Compose {
|
||||
items
|
||||
},
|
||||
)
|
||||
.min_h(px(250.)),
|
||||
.pb_4()
|
||||
.min_h(px(280.)),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div().mt_2().child(
|
||||
Button::new("create_dm_btn")
|
||||
.label(label)
|
||||
.primary()
|
||||
.w_full()
|
||||
.loading(self.is_submitting)
|
||||
.disabled(self.is_submitting)
|
||||
.on_click(cx.listener(|this, _, window, cx| this.compose(window, cx))),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
173
crates/coop/src/views/contacts.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use anyhow::Error;
|
||||
use common::profile::SharedProfile;
|
||||
use global::get_client;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context,
|
||||
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use ui::{
|
||||
button::Button,
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
indicator::Indicator,
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Sizable,
|
||||
};
|
||||
|
||||
const MIN_HEIGHT: f32 = 280.;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Contacts> {
|
||||
Contacts::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Contacts {
|
||||
contacts: Option<Vec<Profile>>,
|
||||
// Panel
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Contacts {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let client = get_client();
|
||||
|
||||
let task: Task<Result<BTreeSet<Profile>, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let profiles = client.database().contacts(public_key).await?;
|
||||
|
||||
Ok(profiles)
|
||||
});
|
||||
|
||||
if let Ok(contacts) = task.await {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.contacts = Some(contacts.into_iter().collect_vec());
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
contacts: None,
|
||||
name: "Contacts".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Contacts {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"ContactPanel".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Contacts {}
|
||||
|
||||
impl Focusable for Contacts {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Contacts {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let entity = cx.entity().clone();
|
||||
|
||||
div().map(|this| {
|
||||
if let Some(contacts) = self.contacts.clone() {
|
||||
this.child(
|
||||
uniform_list(
|
||||
entity,
|
||||
"contacts",
|
||||
contacts.len(),
|
||||
move |_, range, _window, cx| {
|
||||
let mut items = Vec::with_capacity(contacts.len());
|
||||
|
||||
for ix in range {
|
||||
if let Some(item) = contacts.get(ix) {
|
||||
items.push(
|
||||
div()
|
||||
.w_full()
|
||||
.h_9()
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
div().flex_shrink_0().child(
|
||||
img(item.shared_avatar()).size_6(),
|
||||
),
|
||||
)
|
||||
.child(item.shared_name()),
|
||||
)
|
||||
.hover(|this| {
|
||||
this.bg(cx
|
||||
.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::THREE))
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.min_h(px(MIN_HEIGHT)),
|
||||
)
|
||||
} else {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_16()
|
||||
.child(Indicator::new().small())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
418
crates/coop/src/views/login.rs
Normal file
@@ -0,0 +1,418 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use account::Account;
|
||||
use common::create_qr;
|
||||
use global::get_client_keys;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, relative, AnyElement, App, AppContext, Context, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Subscription, Window,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
input::{InputEvent, TextInput},
|
||||
notification::Notification,
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Disableable, Sizable, Size, StyledExt,
|
||||
};
|
||||
|
||||
const INPUT_INVALID: &str = "You must provide a valid Private Key or Bunker.";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
|
||||
Login::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Login {
|
||||
// Inputs
|
||||
key_input: Entity<TextInput>,
|
||||
error_message: Entity<Option<SharedString>>,
|
||||
is_logging_in: bool,
|
||||
// Nostr Connect
|
||||
qr: Option<Arc<Image>>,
|
||||
connect_relay: Entity<TextInput>,
|
||||
connect_client: Entity<Option<NostrConnectURI>>,
|
||||
// Panel
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
#[allow(unused)]
|
||||
subscriptions: SmallVec<[Subscription; 3]>,
|
||||
}
|
||||
|
||||
impl Login {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let mut subscriptions = smallvec![];
|
||||
let error_message = cx.new(|_| None);
|
||||
let connect_client = cx.new(|_: &mut Context<'_, Option<NostrConnectURI>>| None);
|
||||
|
||||
let key_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("nsec... or bunker://...")
|
||||
});
|
||||
|
||||
let connect_relay = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).text_size(Size::Small).small();
|
||||
input.set_text("wss://relay.nsec.app", window, cx);
|
||||
input
|
||||
});
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&key_input,
|
||||
window,
|
||||
move |this, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter = event {
|
||||
this.login(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&connect_relay,
|
||||
window,
|
||||
move |this, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter = event {
|
||||
this.change_relay(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(
|
||||
cx.observe_in(&connect_client, window, |this, uri, window, cx| {
|
||||
let keys = get_client_keys().to_owned();
|
||||
let account = Account::global(cx);
|
||||
|
||||
if let Some(uri) = uri.read(cx).clone() {
|
||||
if let Ok(qr) = create_qr(uri.to_string().as_str()) {
|
||||
this.qr = Some(qr);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
match NostrConnect::new(uri, keys, Duration::from_secs(300), None) {
|
||||
Ok(signer) => {
|
||||
account.update(cx, |this, cx| {
|
||||
this.login(signer, window, cx);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(500))
|
||||
.await;
|
||||
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
let Ok(relay_url) =
|
||||
RelayUrl::parse(this.connect_relay.read(cx).text().to_string().as_str())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let client_pubkey = get_client_keys().public_key();
|
||||
let uri = NostrConnectURI::client(client_pubkey, vec![relay_url], "Coop");
|
||||
|
||||
this.connect_client.update(cx, |this, cx| {
|
||||
*this = Some(uri);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
key_input,
|
||||
connect_relay,
|
||||
connect_client,
|
||||
subscriptions,
|
||||
error_message,
|
||||
qr: None,
|
||||
is_logging_in: false,
|
||||
name: "Login".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.is_logging_in {
|
||||
return;
|
||||
};
|
||||
|
||||
self.set_logging_in(true, cx);
|
||||
|
||||
let content = self.key_input.read(cx).text();
|
||||
let account = Account::global(cx);
|
||||
|
||||
if content.starts_with("nsec1") {
|
||||
match SecretKey::parse(content.as_ref()) {
|
||||
Ok(secret) => {
|
||||
let keys = Keys::new(secret);
|
||||
|
||||
account.update(cx, |this, cx| {
|
||||
this.login(keys, window, cx);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_error_message(e.to_string(), cx);
|
||||
self.set_logging_in(false, cx);
|
||||
}
|
||||
}
|
||||
} else if content.starts_with("bunker://") {
|
||||
let keys = get_client_keys().to_owned();
|
||||
|
||||
let Ok(uri) = NostrConnectURI::parse(content.as_ref()) else {
|
||||
self.set_error_message("Bunker URL is not valid".to_owned(), cx);
|
||||
self.set_logging_in(false, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
match NostrConnect::new(uri, keys, Duration::from_secs(120), None) {
|
||||
Ok(signer) => {
|
||||
account.update(cx, |this, cx| {
|
||||
this.login(signer, window, cx);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_error_message(e.to_string(), cx);
|
||||
self.set_logging_in(false, cx);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
window.push_notification(Notification::error(INPUT_INVALID), cx);
|
||||
self.set_logging_in(false, cx);
|
||||
};
|
||||
}
|
||||
|
||||
fn change_relay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(relay_url) =
|
||||
RelayUrl::parse(self.connect_relay.read(cx).text().to_string().as_str())
|
||||
else {
|
||||
window.push_notification(Notification::error("Relay URL is not valid."), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let client_pubkey = get_client_keys().public_key();
|
||||
let uri = NostrConnectURI::client(client_pubkey, vec![relay_url], "Coop");
|
||||
|
||||
self.connect_client.update(cx, |this, cx| {
|
||||
*this = Some(uri);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn set_error_message(&mut self, message: String, cx: &mut Context<Self>) {
|
||||
self.error_message.update(cx, |this, cx| {
|
||||
*this = Some(SharedString::new(message));
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_logging_in = status;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Login {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Login {}
|
||||
|
||||
impl Focusable for Login {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Login {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.relative()
|
||||
.flex()
|
||||
.child(
|
||||
div()
|
||||
.h_full()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.w_80()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_8()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.text_xl()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.3))
|
||||
.child("Welcome Back!"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.child("Continue with Private Key or Bunker"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(self.key_input.clone())
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label("Continue")
|
||||
.primary()
|
||||
.loading(self.is_logging_in)
|
||||
.disabled(self.is_logging_in)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.login(window, cx);
|
||||
})),
|
||||
)
|
||||
.when_some(
|
||||
self.error_message.read(cx).clone(),
|
||||
|this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error),
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.h_full()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::TWO))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_3()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::TWELVE),
|
||||
)
|
||||
.child("Continue with Nostr Connect"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.child("Use Nostr Connect apps to scan the code"),
|
||||
),
|
||||
)
|
||||
.when_some(self.qr.clone(), |this, qr| {
|
||||
this.child(
|
||||
div()
|
||||
.mb_2()
|
||||
.p_2()
|
||||
.size_64()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.rounded_2xl()
|
||||
.shadow_md()
|
||||
.when(cx.theme().appearance.is_dark(), |this| {
|
||||
this.shadow_none().border_1().border_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::SIX),
|
||||
)
|
||||
})
|
||||
.bg(cx.theme().background)
|
||||
.child(img(qr).h_56()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.child(self.connect_relay.clone())
|
||||
.child(
|
||||
Button::new("change")
|
||||
.label("Change")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.change_relay(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
11
crates/coop/src/views/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod chat;
|
||||
pub mod compose;
|
||||
pub mod contacts;
|
||||
pub mod login;
|
||||
pub mod new_account;
|
||||
pub mod onboarding;
|
||||
pub mod profile;
|
||||
pub mod relays;
|
||||
pub mod sidebar;
|
||||
pub mod subject;
|
||||
pub mod welcome;
|
||||
307
crates/coop/src/views/new_account.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use account::Account;
|
||||
use async_utility::task::spawn;
|
||||
use common::nip96_upload;
|
||||
use global::{constants::IMAGE_SERVICE, get_client};
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, relative, AnyElement, App, AppContext, Context, Entity,
|
||||
EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions,
|
||||
Render, SharedString, Styled, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::fs;
|
||||
use std::str::FromStr;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
input::TextInput,
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Disableable, Icon, IconName, Sizable, Size, StyledExt,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
|
||||
NewAccount::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct NewAccount {
|
||||
name_input: Entity<TextInput>,
|
||||
avatar_input: Entity<TextInput>,
|
||||
bio_input: Entity<TextInput>,
|
||||
is_uploading: bool,
|
||||
is_submitting: bool,
|
||||
// Panel
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl NewAccount {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let name_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("Alice")
|
||||
});
|
||||
|
||||
let avatar_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.small()
|
||||
.placeholder("https://example.com/avatar.jpg")
|
||||
});
|
||||
|
||||
let bio_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.multi_line()
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
|
||||
Self {
|
||||
name_input,
|
||||
avatar_input,
|
||||
bio_input,
|
||||
is_uploading: false,
|
||||
is_submitting: false,
|
||||
name: "New Account".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_submitting(true, cx);
|
||||
|
||||
let avatar = self.avatar_input.read(cx).text().to_string();
|
||||
let name = self.name_input.read(cx).text().to_string();
|
||||
let bio = self.bio_input.read(cx).text().to_string();
|
||||
|
||||
let mut metadata = Metadata::new().display_name(name).about(bio);
|
||||
|
||||
if let Ok(url) = Url::from_str(&avatar) {
|
||||
metadata = metadata.picture(url);
|
||||
};
|
||||
|
||||
Account::global(cx).update(cx, |this, cx| {
|
||||
this.new_account(metadata, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let avatar_input = self.avatar_input.downgrade();
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
self.set_uploading(true, cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
let Some(path) = paths.pop() else {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(file_data) = fs::read(path).await {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Url>();
|
||||
|
||||
spawn(async move {
|
||||
if let Ok(url) = nip96_upload(client, file_data).await {
|
||||
_ = tx.send(url);
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(url) = rx.await {
|
||||
cx.update(|window, cx| {
|
||||
// Stop loading spinner
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Set avatar input
|
||||
avatar_input
|
||||
.update(cx, |this, cx| {
|
||||
this.set_text(url.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_uploading(false, cx);
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_submitting = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_uploading = status;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for NewAccount {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for NewAccount {}
|
||||
|
||||
impl Focusable for NewAccount {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NewAccount {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.relative()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_10()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.text_lg()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.3))
|
||||
.child("Create New Account"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_72()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.h_32()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.map(|this| {
|
||||
if self.avatar_input.read(cx).text().is_empty() {
|
||||
this.child(img("brand/avatar.png").size_10().flex_shrink_0())
|
||||
} else {
|
||||
this.child(
|
||||
img(format!(
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
|
||||
IMAGE_SERVICE,
|
||||
self.avatar_input.read(cx).text()
|
||||
))
|
||||
.size_10()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.label("Set Profile Picture")
|
||||
.icon(Icon::new(IconName::Plus))
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(self.is_submitting)
|
||||
.loading(self.is_uploading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Name *:")
|
||||
.child(self.name_input.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Bio:")
|
||||
.child(self.bio_input.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.my_2()
|
||||
.w_full()
|
||||
.h_px()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
Button::new("submit")
|
||||
.label("Continue")
|
||||
.primary()
|
||||
.loading(self.is_submitting)
|
||||
.disabled(self.is_submitting || self.is_uploading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.submit(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
140
crates/coop/src/views/onboarding.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use gpui::{
|
||||
div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Icon, IconName, StyledExt,
|
||||
};
|
||||
|
||||
use crate::chatspace;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
|
||||
Onboarding::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Onboarding {
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Onboarding {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
name: "Onboarding".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Onboarding {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Onboarding {}
|
||||
|
||||
impl Focusable for Onboarding {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Onboarding {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const TITLE: &str = "Welcome to Coop!";
|
||||
const SUBTITLE: &str = "Secure Communication on Nostr.";
|
||||
|
||||
div()
|
||||
.py_4()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_10()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_16()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_xl()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.3))
|
||||
.child(TITLE),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(SUBTITLE),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_72()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("continue_btn")
|
||||
.icon(Icon::new(IconName::ArrowRight))
|
||||
.label("Start Messaging")
|
||||
.primary()
|
||||
.reverse()
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
chatspace::new_account(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("login_btn")
|
||||
.label("Already have an account? Log in.")
|
||||
.ghost()
|
||||
.underline()
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
chatspace::login(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,39 @@
|
||||
use async_utility::task::spawn;
|
||||
use common::utils::nip96_upload;
|
||||
use common::nip96_upload;
|
||||
use global::{constants::IMAGE_SERVICE, get_client};
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render,
|
||||
SharedString, Styled, Task, Window,
|
||||
div, img, prelude::FluentBuilder, px, App, AppContext, Context, Entity, Flatten, IntoElement,
|
||||
ParentElement, PathPromptOptions, Render, Styled, Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::fs;
|
||||
use std::{str::FromStr, sync::Arc, time::Duration};
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
input::TextInput,
|
||||
popup_menu::PopupMenu,
|
||||
ContextModal, Disableable, Sizable, Size,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Disableable, IconName, Sizable, Size,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Arc<Entity<Profile>> {
|
||||
Arc::new(Profile::new(window, cx))
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Profile> {
|
||||
Profile::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Profile {
|
||||
profile: Option<Metadata>,
|
||||
// Form
|
||||
name_input: Entity<TextInput>,
|
||||
avatar_input: Entity<TextInput>,
|
||||
bio_input: Entity<TextInput>,
|
||||
website_input: Entity<TextInput>,
|
||||
is_loading: bool,
|
||||
is_submitting: bool,
|
||||
// Panel
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
let name_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("Alice")
|
||||
});
|
||||
|
||||
@@ -54,13 +46,13 @@ impl Profile {
|
||||
|
||||
let website_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("https://your-website.com")
|
||||
});
|
||||
|
||||
let bio_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.text_size(Size::Small)
|
||||
.multi_line()
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
@@ -74,12 +66,10 @@ impl Profile {
|
||||
profile: None,
|
||||
is_loading: false,
|
||||
is_submitting: false,
|
||||
name: "Profile".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
};
|
||||
|
||||
let client = get_client();
|
||||
let task: Task<Result<Option<Metadata>, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let metadata = client
|
||||
@@ -89,10 +79,10 @@ impl Profile {
|
||||
Ok(metadata)
|
||||
});
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(Some(metadata)) = task.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this: &mut Profile, cx| {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this: &mut Profile, cx| {
|
||||
this.avatar_input.update(cx, |this, cx| {
|
||||
if let Some(avatar) = metadata.picture.as_ref() {
|
||||
this.set_text(avatar, window, cx);
|
||||
@@ -114,9 +104,12 @@ impl Profile {
|
||||
}
|
||||
});
|
||||
this.profile = Some(metadata);
|
||||
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
@@ -127,7 +120,6 @@ impl Profile {
|
||||
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let avatar_input = self.avatar_input.downgrade();
|
||||
let window_handle = window.window_handle();
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
@@ -137,7 +129,7 @@ impl Profile {
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
let path = paths.pop().unwrap();
|
||||
@@ -153,32 +145,33 @@ impl Profile {
|
||||
});
|
||||
|
||||
if let Ok(url) = rx.await {
|
||||
cx.update_window(window_handle, |_, window, cx| {
|
||||
cx.update(|window, cx| {
|
||||
// Stop loading spinner
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.unwrap();
|
||||
.ok();
|
||||
|
||||
// Set avatar input
|
||||
avatar_input
|
||||
.update(cx, |this, cx| {
|
||||
this.set_text(url.to_string(), window, cx);
|
||||
})
|
||||
.unwrap();
|
||||
.ok();
|
||||
})
|
||||
.unwrap();
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// Stop loading spinner
|
||||
if let Some(view) = this.upgrade() {
|
||||
cx.update_entity(&view, |this, cx| {
|
||||
cx.update(|_, cx| {
|
||||
// Stop loading spinner
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
@@ -186,11 +179,6 @@ impl Profile {
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Show loading spinner
|
||||
self.set_submitting(true, cx);
|
||||
@@ -216,92 +204,62 @@ impl Profile {
|
||||
new_metadata = new_metadata.website(url);
|
||||
}
|
||||
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<EventId>();
|
||||
_ = client.set_metadata(&new_metadata).await?;
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(output) = client.set_metadata(&new_metadata).await {
|
||||
_ = tx.send(output.val);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
Ok(())
|
||||
});
|
||||
|
||||
if rx.await.is_ok() {
|
||||
cx.update_window(window_handle, |_, window, cx| {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_submitting(false, cx);
|
||||
window.push_notification("Your profile has been updated successfully", cx);
|
||||
})
|
||||
.unwrap()
|
||||
.ok();
|
||||
})
|
||||
.unwrap();
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_submitting = status;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Profile {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"ProfilePanel".into()
|
||||
}
|
||||
|
||||
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 Profile {}
|
||||
|
||||
impl Focusable for Profile {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Profile {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.px_2()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.h_32()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::TWO))
|
||||
.rounded(px(cx.theme().radius))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.h_24()
|
||||
.map(|this| {
|
||||
let picture = self.avatar_input.read(cx).text();
|
||||
|
||||
if picture.is_empty() {
|
||||
this.child(
|
||||
img("brand/avatar.jpg")
|
||||
.size_10()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
this.child(img("brand/avatar.png").size_10().flex_shrink_0())
|
||||
} else {
|
||||
this.child(
|
||||
img(format!(
|
||||
@@ -310,29 +268,21 @@ impl Render for Profile {
|
||||
self.avatar_input.read(cx).text()
|
||||
))
|
||||
.size_10()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.w_full()
|
||||
.child(self.avatar_input.clone())
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.label("Upload")
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(self.is_submitting)
|
||||
.loading(self.is_loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
),
|
||||
Button::new("upload")
|
||||
.icon(IconName::Upload)
|
||||
.label("Change")
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(self.is_loading || self.is_submitting)
|
||||
.loading(self.is_loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -340,7 +290,7 @@ impl Render for Profile {
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_sm()
|
||||
.child("Name:")
|
||||
.child(self.name_input.clone()),
|
||||
)
|
||||
@@ -349,26 +299,25 @@ impl Render for Profile {
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child("Bio:")
|
||||
.child(self.bio_input.clone()),
|
||||
.text_sm()
|
||||
.child("Website:")
|
||||
.child(self.website_input.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child("Website:")
|
||||
.child(self.website_input.clone()),
|
||||
.text_sm()
|
||||
.child("Bio:")
|
||||
.child(self.bio_input.clone()),
|
||||
)
|
||||
.child(
|
||||
div().flex().items_center().justify_end().child(
|
||||
div().mt_2().w_full().child(
|
||||
Button::new("submit")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.small()
|
||||
.disabled(self.is_loading)
|
||||
.disabled(self.is_loading || self.is_submitting)
|
||||
.loading(self.is_submitting)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.submit(window, cx);
|
||||
@@ -1,9 +1,9 @@
|
||||
use anyhow::{anyhow, Error};
|
||||
use anyhow::Error;
|
||||
use global::{constants::NEW_MESSAGE_SUB_ID, get_client};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, px, uniform_list, App, AppContext, Context, Entity, FocusHandle,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription, Task, TextAlign,
|
||||
Window,
|
||||
UniformList, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
@@ -11,12 +11,11 @@ use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, IconName, Sizable,
|
||||
ContextModal, Disableable, IconName, Sizable,
|
||||
};
|
||||
|
||||
use crate::device::Device;
|
||||
|
||||
const MESSAGE: &str = "In order to receive messages from others, you need to setup Messaging Relays. You can use the recommend relays or add more.";
|
||||
const MIN_HEIGHT: f32 = 200.0;
|
||||
const MESSAGE: &str = "In order to receive messages from others, you need to setup at least one Messaging Relay. You can use the recommend relays or add more.";
|
||||
const HELP_TEXT: &str = "Please add some relays.";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Relays> {
|
||||
@@ -34,15 +33,16 @@ pub struct Relays {
|
||||
|
||||
impl Relays {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let client = get_client();
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(ui::Size::XSmall)
|
||||
.small()
|
||||
.placeholder("wss://example.com")
|
||||
});
|
||||
|
||||
let relays = cx.new(|cx| {
|
||||
let relays = vec![
|
||||
RelayUrl::parse("wss://auth.nostr1.com").unwrap(),
|
||||
RelayUrl::parse("wss://relay.0xchat.com").unwrap(),
|
||||
];
|
||||
|
||||
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
@@ -54,39 +54,36 @@ impl Relays {
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let relays = event
|
||||
.tags
|
||||
.filter_standardized(TagKind::Relay)
|
||||
.filter_map(|t| match t {
|
||||
TagStandard::Relay(url) => Some(url.to_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.filter(TagKind::Relay)
|
||||
.filter_map(|tag| RelayUrl::parse(tag.content()?).ok())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(relays)
|
||||
} else {
|
||||
Err(anyhow!("Messaging Relays not found."))
|
||||
let relays = vec![
|
||||
RelayUrl::parse("wss://auth.nostr1.com")?,
|
||||
RelayUrl::parse("wss://relay.0xchat.com")?,
|
||||
];
|
||||
|
||||
Ok(relays)
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(relays) = task.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this: &mut Vec<RelayUrl>, cx| {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this: &mut Vec<RelayUrl>, cx| {
|
||||
*this = relays;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
relays
|
||||
});
|
||||
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(ui::Size::XSmall)
|
||||
.small()
|
||||
.placeholder("wss://example.com")
|
||||
vec![]
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
@@ -113,12 +110,9 @@ impl Relays {
|
||||
}
|
||||
|
||||
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = self.relays.read(cx).clone();
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let relays = self.relays.read(cx).clone();
|
||||
let task: Task<Result<EventId, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
@@ -173,26 +167,14 @@ impl Relays {
|
||||
Ok(output.val)
|
||||
});
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
if let Some(device) = Device::global(cx) {
|
||||
let relays = this
|
||||
.read_with(cx, |this, cx| this.relays.read(cx).clone())
|
||||
.unwrap();
|
||||
|
||||
device.update(cx, |this, cx| {
|
||||
if let Some(profile) = this.profile() {
|
||||
let new_profile = profile.clone().relays(Some(relays.into()));
|
||||
this.set_profile(new_profile, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
|
||||
window.close_modal(cx);
|
||||
})
|
||||
@@ -202,10 +184,6 @@ impl Relays {
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn loading(&self) -> bool {
|
||||
self.is_loading
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
@@ -238,116 +216,135 @@ impl Relays {
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn render_list(
|
||||
&mut self,
|
||||
relays: Vec<RelayUrl>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> UniformList {
|
||||
let view = cx.entity();
|
||||
let total = relays.len();
|
||||
|
||||
uniform_list(view, "relays", total, move |_, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let item = relays.get(ix).unwrap().clone().to_string();
|
||||
|
||||
items.push(
|
||||
div().group("").w_full().h_9().py_0p5().child(
|
||||
div()
|
||||
.px_2()
|
||||
.h_full()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.text_xs()
|
||||
.child(item)
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.remove(ix, window, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
.w_full()
|
||||
.min_h(px(MIN_HEIGHT))
|
||||
}
|
||||
|
||||
fn render_empty(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.h_20()
|
||||
.mb_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(HELP_TEXT)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Relays {
|
||||
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 {
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(MESSAGE),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(self.input.clone())
|
||||
.child(
|
||||
Button::new("add_relay_btn")
|
||||
.icon(IconName::Plus)
|
||||
.small()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| this.add(window, cx)),
|
||||
),
|
||||
),
|
||||
.text_sm()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(MESSAGE),
|
||||
)
|
||||
.map(|this| {
|
||||
let view = cx.entity();
|
||||
let relays = self.relays.read(cx).clone();
|
||||
let total = relays.len();
|
||||
|
||||
if !relays.is_empty() {
|
||||
this.child(
|
||||
uniform_list(
|
||||
view,
|
||||
"relays",
|
||||
total,
|
||||
move |_, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let item = relays.get(ix).unwrap().clone().to_string();
|
||||
|
||||
items.push(
|
||||
div().group("").w_full().h_9().py_0p5().child(
|
||||
div()
|
||||
.px_2()
|
||||
.h_full()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.bg(cx
|
||||
.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::THREE))
|
||||
.text_xs()
|
||||
.child(item)
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| {
|
||||
this.visible()
|
||||
})
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.remove(ix, window, cx)
|
||||
},
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.w_full()
|
||||
.min_h(px(120.)),
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(self.input.clone())
|
||||
.child(
|
||||
Button::new("add_relay_btn")
|
||||
.icon(IconName::Plus)
|
||||
.label("Add")
|
||||
.small()
|
||||
.ghost()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.add(window, cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.h_20()
|
||||
.mb_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(HELP_TEXT)
|
||||
}
|
||||
}),
|
||||
.map(|this| {
|
||||
let relays = self.relays.read(cx).clone();
|
||||
|
||||
if !relays.is_empty() {
|
||||
this.child(self.render_list(relays, window, cx))
|
||||
} else {
|
||||
this.child(self.render_empty(window, cx))
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("submti")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.w_full()
|
||||
.loading(self.is_loading)
|
||||
.disabled(self.is_loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.update(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
58
crates/coop/src/views/sidebar/button.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, px, App, ClickEvent, Div, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Icon,
|
||||
};
|
||||
|
||||
type Handler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct SidebarButton {
|
||||
base: Div,
|
||||
label: SharedString,
|
||||
icon: Option<Icon>,
|
||||
handler: Handler,
|
||||
}
|
||||
|
||||
impl SidebarButton {
|
||||
pub fn new(label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
base: div().flex().items_center().gap_3().px_3().h_8(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
handler: Rc::new(|_, _, _| {}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Rc::new(handler);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SidebarButton {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let handler = self.handler.clone();
|
||||
|
||||
self.base
|
||||
.id(self.label.clone())
|
||||
.rounded(px(cx.theme().radius))
|
||||
.when_some(self.icon, |this, icon| this.child(icon))
|
||||
.child(self.label.clone())
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(move |ev, window, cx| handler(ev, window, cx))
|
||||
}
|
||||
}
|
||||
312
crates/coop/src/views/sidebar/folder.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
div, percentage, prelude::FluentBuilder, px, App, ClickEvent, Div, Img, InteractiveElement,
|
||||
IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
|
||||
Window,
|
||||
};
|
||||
use ui::{
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Collapsible, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
type Handler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Parent {
|
||||
base: Div,
|
||||
icon: Option<Icon>,
|
||||
label: SharedString,
|
||||
items: Vec<Folder>,
|
||||
collapsed: bool,
|
||||
handler: Handler,
|
||||
}
|
||||
|
||||
impl Parent {
|
||||
pub fn new(label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
base: div().flex().flex_col().gap_2(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
items: Vec::new(),
|
||||
collapsed: false,
|
||||
handler: Rc::new(|_, _, _| {}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn child(mut self, child: impl Into<Folder>) -> Self {
|
||||
self.items.push(child.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Folder>>) -> Self {
|
||||
self.items = children.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Rc::new(handler);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Collapsible for Parent {
|
||||
fn is_collapsed(&self) -> bool {
|
||||
self.collapsed
|
||||
}
|
||||
|
||||
fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Parent {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let handler = self.handler.clone();
|
||||
|
||||
self.base
|
||||
.child(
|
||||
div()
|
||||
.id(self.label.clone())
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.h_8()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.text_sm()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.font_medium()
|
||||
.child(
|
||||
Icon::new(IconName::CaretDown)
|
||||
.xsmall()
|
||||
.when(self.collapsed, |this| this.rotate(percentage(270. / 360.))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.when_some(self.icon, |this, icon| this.child(icon.small()))
|
||||
.child(self.label.clone()),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(move |ev, window, cx| handler(ev, window, cx)),
|
||||
)
|
||||
.when(!self.collapsed, |this| {
|
||||
this.child(div().flex().flex_col().gap_2().pl_3().children(self.items))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Folder {
|
||||
base: Div,
|
||||
icon: Option<Icon>,
|
||||
label: SharedString,
|
||||
items: Vec<FolderItem>,
|
||||
collapsed: bool,
|
||||
handler: Handler,
|
||||
}
|
||||
|
||||
impl Folder {
|
||||
pub fn new(label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
base: div().flex().flex_col().gap_2(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
items: Vec::new(),
|
||||
collapsed: false,
|
||||
handler: Rc::new(|_, _, _| {}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<FolderItem>>) -> Self {
|
||||
self.items = children.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Rc::new(handler);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Collapsible for Folder {
|
||||
fn is_collapsed(&self) -> bool {
|
||||
self.collapsed
|
||||
}
|
||||
|
||||
fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Folder {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let handler = self.handler.clone();
|
||||
|
||||
self.base
|
||||
.child(
|
||||
div()
|
||||
.id(self.label.clone())
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.h_8()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.text_sm()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.font_medium()
|
||||
.child(
|
||||
Icon::new(IconName::CaretDown)
|
||||
.xsmall()
|
||||
.when(self.collapsed, |this| this.rotate(percentage(270. / 360.))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.when_some(self.icon, |this, icon| this.child(icon.small()))
|
||||
.child(self.label.clone()),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(move |ev, window, cx| handler(ev, window, cx)),
|
||||
)
|
||||
.when(!self.collapsed, |this| {
|
||||
this.child(div().flex().flex_col().gap_1().pl_6().children(self.items))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct FolderItem {
|
||||
ix: usize,
|
||||
base: Div,
|
||||
img: Option<Img>,
|
||||
label: Option<SharedString>,
|
||||
description: Option<SharedString>,
|
||||
handler: Handler,
|
||||
}
|
||||
|
||||
impl FolderItem {
|
||||
pub fn new(ix: usize) -> Self {
|
||||
Self {
|
||||
ix,
|
||||
base: div().h_8().w_full().px_2(),
|
||||
img: None,
|
||||
label: None,
|
||||
description: None,
|
||||
handler: Rc::new(|_, _, _| {}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
|
||||
self.description = Some(description.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn img(mut self, img: Option<Img>) -> Self {
|
||||
self.img = img;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Rc::new(handler);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FolderItem {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let handler = self.handler.clone();
|
||||
|
||||
self.base
|
||||
.id(self.ix)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.text_sm()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.truncate()
|
||||
.font_medium()
|
||||
.map(|this| {
|
||||
if let Some(img) = self.img {
|
||||
this.child(img.size_5().flex_shrink_0())
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().accent.step(cx, ColorScaleStep::THREE))
|
||||
.child(
|
||||
Icon::new(IconName::UsersThreeFill).xsmall().text_color(
|
||||
cx.theme().accent.step(cx, ColorScaleStep::TWELVE),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
.when_some(self.label, |this, label| this.child(label)),
|
||||
)
|
||||
.when_some(self.description, |this, description| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::TEN))
|
||||
.child(description),
|
||||
)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(move |ev, window, cx| handler(ev, window, cx))
|
||||
}
|
||||
}
|
||||
337
crates/coop/src/views/sidebar/mod.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
use account::Account;
|
||||
use button::SidebarButton;
|
||||
use chats::{
|
||||
room::{Room, RoomKind},
|
||||
ChatRegistry,
|
||||
};
|
||||
use common::profile::SharedProfile;
|
||||
use folder::{Folder, FolderItem, Parent};
|
||||
use global::get_client;
|
||||
use gpui::{
|
||||
actions, div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
SharedString, Styled, Task, Window,
|
||||
};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::{
|
||||
dock::DockPlacement,
|
||||
panel::{Panel, PanelEvent},
|
||||
},
|
||||
popup_menu::{PopupMenu, PopupMenuExt},
|
||||
scroll::ScrollbarAxis,
|
||||
skeleton::Skeleton,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
use crate::chatspace::{AddPanel, ModalKind, PanelKind, ToggleModal};
|
||||
|
||||
mod button;
|
||||
mod folder;
|
||||
|
||||
actions!(profile, [Logout]);
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
|
||||
Sidebar::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Sidebar {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
ongoing: bool,
|
||||
incoming: bool,
|
||||
trusted: bool,
|
||||
unknown: bool,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
|
||||
Self {
|
||||
name: "Chat Sidebar".into(),
|
||||
ongoing: false,
|
||||
incoming: false,
|
||||
trusted: true,
|
||||
unknown: true,
|
||||
focus_handle,
|
||||
}
|
||||
}
|
||||
|
||||
fn ongoing(&mut self, cx: &mut Context<Self>) {
|
||||
self.ongoing = !self.ongoing;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn incoming(&mut self, cx: &mut Context<Self>) {
|
||||
self.incoming = !self.incoming;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn trusted(&mut self, cx: &mut Context<Self>) {
|
||||
self.trusted = !self.trusted;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn unknown(&mut self, cx: &mut Context<Self>) {
|
||||
self.unknown = !self.unknown;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_skeleton(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
|
||||
(0..total).map(|_| {
|
||||
div()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
|
||||
.child(Skeleton::new().w_20().h_3().rounded_sm())
|
||||
})
|
||||
}
|
||||
|
||||
fn render_items(rooms: &Vec<&Entity<Room>>, cx: &Context<Self>) -> Vec<FolderItem> {
|
||||
let mut items = Vec::with_capacity(rooms.len());
|
||||
|
||||
for room in rooms {
|
||||
let room = room.read(cx);
|
||||
let id = room.id;
|
||||
let ago = room.ago();
|
||||
let label = room.display_name(cx);
|
||||
let img = room.display_image(cx).map(img);
|
||||
|
||||
let item = FolderItem::new(id as usize)
|
||||
.label(label)
|
||||
.description(ago)
|
||||
.img(img)
|
||||
.on_click({
|
||||
cx.listener(move |_, _, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
});
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn on_logout(&mut self, _: &Logout, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let task: Task<Result<(), anyhow::Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
_ = client.reset().await;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|_, cx| {
|
||||
Account::global(cx).update(cx, |this, cx| {
|
||||
this.profile = None;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Sidebar {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Sidebar {}
|
||||
|
||||
impl Focusable for Sidebar {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Sidebar {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let account = Account::global(cx).read(cx).profile.as_ref();
|
||||
let registry = ChatRegistry::global(cx).read(cx);
|
||||
|
||||
let rooms = registry.rooms(cx);
|
||||
let loading = registry.loading();
|
||||
|
||||
let ongoing = rooms.get(&RoomKind::Ongoing);
|
||||
let trusted = rooms.get(&RoomKind::Trusted);
|
||||
let unknown = rooms.get(&RoomKind::Unknown);
|
||||
|
||||
div()
|
||||
.scrollable(cx.entity_id(), ScrollbarAxis::Vertical)
|
||||
.on_action(cx.listener(Self::on_logout))
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.pt_1()
|
||||
.px_2()
|
||||
.pb_2()
|
||||
.when_some(account, |this, profile| {
|
||||
this.child(
|
||||
div()
|
||||
.h_7()
|
||||
.px_1p5()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(img(profile.shared_avatar()).size_7())
|
||||
.child(profile.shared_name()),
|
||||
)
|
||||
.child(
|
||||
Button::new("user_dropdown")
|
||||
.icon(IconName::Ellipsis)
|
||||
.small()
|
||||
.ghost()
|
||||
.popup_menu(|this, _window, _cx| {
|
||||
this.menu(
|
||||
"Profile",
|
||||
Box::new(ToggleModal {
|
||||
modal: ModalKind::Profile,
|
||||
}),
|
||||
)
|
||||
.menu(
|
||||
"Relays",
|
||||
Box::new(ToggleModal {
|
||||
modal: ModalKind::Relay,
|
||||
}),
|
||||
)
|
||||
.separator()
|
||||
.menu("Logout", Box::new(Logout))
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(
|
||||
SidebarButton::new("New Message")
|
||||
.icon(IconName::PlusCircleFill)
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(ToggleModal {
|
||||
modal: ModalKind::Compose,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
SidebarButton::new("Contacts")
|
||||
.icon(IconName::AddressBook)
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(ToggleModal {
|
||||
modal: ModalKind::Contact,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::NINE))
|
||||
.child("Messages"),
|
||||
)
|
||||
.map(|this| {
|
||||
if loading {
|
||||
this.children(self.render_skeleton(6))
|
||||
} else {
|
||||
this.when_some(ongoing, |this, rooms| {
|
||||
this.child(
|
||||
Folder::new("Ongoing")
|
||||
.icon(IconName::Folder)
|
||||
.collapsed(self.ongoing)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.ongoing(cx);
|
||||
}))
|
||||
.children(Self::render_items(rooms, cx)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Parent::new("Incoming")
|
||||
.icon(IconName::Folder)
|
||||
.collapsed(self.incoming)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.incoming(cx);
|
||||
}))
|
||||
.when_some(trusted, |this, rooms| {
|
||||
this.child(
|
||||
Folder::new("Trusted")
|
||||
.icon(IconName::Folder)
|
||||
.collapsed(self.trusted)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.trusted(cx);
|
||||
}))
|
||||
.children(Self::render_items(rooms, cx)),
|
||||
)
|
||||
})
|
||||
.when_some(unknown, |this, rooms| {
|
||||
this.child(
|
||||
Folder::new("Unknown")
|
||||
.icon(IconName::Folder)
|
||||
.collapsed(self.unknown)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.unknown(cx);
|
||||
}))
|
||||
.children(Self::render_items(rooms, cx)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
111
crates/coop/src/views/subject.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use chats::ChatRegistry;
|
||||
use gpui::{
|
||||
div, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::TextInput,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Size,
|
||||
};
|
||||
|
||||
pub fn init(
|
||||
id: u64,
|
||||
subject: Option<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Subject> {
|
||||
Subject::new(id, subject, window, cx)
|
||||
}
|
||||
|
||||
pub struct Subject {
|
||||
id: u64,
|
||||
input: Entity<TextInput>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
pub fn new(
|
||||
id: u64,
|
||||
subject: Option<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let input = cx.new(|cx| {
|
||||
let mut this = TextInput::new(window, cx).text_size(Size::Small);
|
||||
if let Some(text) = subject.clone() {
|
||||
this.set_text(text, window, cx);
|
||||
} else {
|
||||
this.set_placeholder("prepare for holidays...");
|
||||
}
|
||||
this
|
||||
});
|
||||
|
||||
cx.new(|cx| Self {
|
||||
id,
|
||||
input,
|
||||
focus_handle: cx.focus_handle(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let registry = ChatRegistry::global(cx).read(cx);
|
||||
let subject = self.input.read(cx).text();
|
||||
|
||||
if subject.is_empty() {
|
||||
window.push_notification("Subject cannot be empty", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(room) = registry.room(&self.id, cx) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.subject = Some(subject);
|
||||
cx.notify();
|
||||
});
|
||||
window.close_modal(cx);
|
||||
} else {
|
||||
window.push_notification("Room not found", cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Subject {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const HELP_TEXT: &str = "Subject will be updated when you send a message.";
|
||||
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child("Subject:"),
|
||||
)
|
||||
.child(self.input.clone())
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.italic()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::NINE))
|
||||
.child(HELP_TEXT),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("submit")
|
||||
.label("Change")
|
||||
.primary()
|
||||
.w_full()
|
||||
.on_click(cx.listener(|this, _, window, cx| this.update(window, cx))),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,11 @@ impl Welcome {
|
||||
|
||||
impl Panel for Welcome {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"WelcomePanel".into()
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
"👋".into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
@@ -92,8 +92,8 @@ impl Render for Welcome {
|
||||
.child(
|
||||
div()
|
||||
.child("coop on nostr.")
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::FOUR))
|
||||
.font_black()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::NINE))
|
||||
.font_semibold()
|
||||
.text_sm(),
|
||||
),
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "global"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
pub const APP_NAME: &str = "Coop";
|
||||
pub const APP_ID: &str = "su.reya.coop";
|
||||
|
||||
pub const CLIENT_KEYRING: &str = "Coop Client Keys";
|
||||
pub const MASTER_KEYRING: &str = "Coop Master Keys";
|
||||
|
||||
pub const DEVICE_ANNOUNCEMENT_KIND: u16 = 10044;
|
||||
pub const DEVICE_REQUEST_KIND: u16 = 4454;
|
||||
pub const DEVICE_RESPONSE_KIND: u16 = 4455;
|
||||
pub const APP_PUBKEY: &str = "b1813fb01274b32cc5db6d1198e7c79dda0fb430899f63c7064f651a41d44f2b";
|
||||
|
||||
/// Bootstrap relays
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://user.kindpag.es",
|
||||
"wss://relaydiscovery.com",
|
||||
"wss://purplepag.es",
|
||||
];
|
||||
|
||||
/// Subscriptions
|
||||
pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps";
|
||||
pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps";
|
||||
pub const DEVICE_SUB_ID: &str = "listen_device_announcement";
|
||||
|
||||
/// Image Resizer Service
|
||||
pub const IMAGE_SERVICE: &str = "https://wsrv.nl";
|
||||
|
||||
/// NIP96 Media Server
|
||||
pub const NIP96_SERVER: &str = "https://nostrmedia.com";
|
||||
|
||||
/// Updater Public Key
|
||||
pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkxM0EzQTQyRTBBMENENTYKUldSV3phRGdRam82a1dtU0JqYll4VnBaVUpSWUxCWlVQbnRkUnNERS96MzFMWDhqNW5zOXplMEwK";
|
||||
/// Updater Server URL
|
||||
pub const UPDATER_URL: &str =
|
||||
"https://cdn.crabnebula.app/update/lume/coop/{{target}}-{{arch}}/{{current_version}}";
|
||||
|
||||
@@ -1,104 +1,37 @@
|
||||
use constants::{ALL_MESSAGES_SUB_ID, APP_ID};
|
||||
use dirs::config_dir;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::lock::Mutex;
|
||||
use paths::nostr_file;
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
sync::{Arc, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
use std::{sync::OnceLock, time::Duration};
|
||||
|
||||
pub mod constants;
|
||||
pub mod paths;
|
||||
|
||||
/// Nostr Client
|
||||
static CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
/// Current App Name
|
||||
static APP_NAME: OnceLock<Arc<str>> = OnceLock::new();
|
||||
/// NIP-4e: Device Keys, used for encryption
|
||||
static DEVICE_KEYS: Mutex<Option<Arc<dyn NostrSigner>>> = Mutex::new(None);
|
||||
/// NIP-4e: Device Name, used for display purposes
|
||||
static DEVICE_NAME: Mutex<Option<Arc<String>>> = Mutex::new(None);
|
||||
static CLIENT_KEYS: OnceLock<Keys> = OnceLock::new();
|
||||
|
||||
/// Nostr Client instance
|
||||
pub fn get_client() -> &'static Client {
|
||||
CLIENT.get_or_init(|| {
|
||||
// Setup app data folder
|
||||
let config_dir = config_dir().expect("Config directory not found");
|
||||
let app_dir = config_dir.join(APP_ID);
|
||||
|
||||
// Create app directory if it doesn't exist
|
||||
_ = fs::create_dir_all(&app_dir);
|
||||
|
||||
// Setup database
|
||||
let lmdb = NostrLMDB::open(app_dir.join("nostr")).expect("Database is NOT initialized");
|
||||
let db_path = nostr_file();
|
||||
let lmdb = NostrLMDB::open(db_path).expect("Database is NOT initialized");
|
||||
|
||||
// Client options
|
||||
let opts = Options::new()
|
||||
// NIP-65
|
||||
// Coop is don't really need to enable this option,
|
||||
// but this will help the client discover user's messaging relays efficiently.
|
||||
.gossip(true)
|
||||
// Skip all very slow relays
|
||||
.max_avg_latency(Duration::from_secs(2));
|
||||
// Note: max delay is 800ms
|
||||
.max_avg_latency(Duration::from_millis(800));
|
||||
|
||||
// Setup Nostr Client
|
||||
ClientBuilder::default().database(lmdb).opts(opts).build()
|
||||
})
|
||||
}
|
||||
|
||||
/// Get app name
|
||||
pub fn get_app_name() -> &'static str {
|
||||
APP_NAME.get_or_init(|| {
|
||||
Arc::from(format!(
|
||||
"Coop on {} ({})",
|
||||
whoami::distro(),
|
||||
whoami::devicename()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Get device keys
|
||||
pub async fn get_device_keys() -> Option<Arc<dyn NostrSigner>> {
|
||||
let guard = DEVICE_KEYS.lock().await;
|
||||
guard.clone()
|
||||
}
|
||||
|
||||
/// Set device keys
|
||||
pub async fn set_device_keys<T>(signer: Arc<T>)
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
DEVICE_KEYS.lock().await.replace(signer);
|
||||
|
||||
// Re-subscribe to all messages
|
||||
smol::spawn(async move {
|
||||
let client = get_client();
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
if let Ok(signer) = client.signer().await {
|
||||
let public_key = signer.get_public_key().await.unwrap();
|
||||
|
||||
// Create a filter for getting all gift wrapped events send to current user
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
|
||||
let id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
|
||||
_ = client.unsubscribe(&id);
|
||||
_ = client.subscribe_with_id(id, filter, Some(opts)).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Set master's device name
|
||||
pub async fn set_device_name(name: &str) {
|
||||
let mut guard = DEVICE_NAME.lock().await;
|
||||
|
||||
if guard.is_none() {
|
||||
guard.replace(Arc::new(name.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get master's device name
|
||||
pub fn get_device_name() -> Arc<String> {
|
||||
let guard = DEVICE_NAME.lock_blocking();
|
||||
guard.clone().unwrap_or(Arc::new("Main Device".into()))
|
||||
/// Client Keys
|
||||
pub fn get_client_keys() -> &'static Keys {
|
||||
CLIENT_KEYS.get_or_init(Keys::generate)
|
||||
}
|
||||
|
||||
64
crates/global/src/paths.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Returns the path to the user's home directory.
|
||||
pub fn home_dir() -> &'static PathBuf {
|
||||
static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
|
||||
}
|
||||
|
||||
/// Returns the path to the configuration directory used by Coop.
|
||||
pub fn config_dir() -> &'static PathBuf {
|
||||
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
CONFIG_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "windows") {
|
||||
return dirs::config_dir()
|
||||
.expect("failed to determine RoamingAppData directory")
|
||||
.join("Coop");
|
||||
}
|
||||
|
||||
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
return if let Ok(flatpak_xdg_config) = std::env::var("FLATPAK_XDG_CONFIG_HOME") {
|
||||
flatpak_xdg_config.into()
|
||||
} else {
|
||||
dirs::config_dir().expect("failed to determine XDG_CONFIG_HOME directory")
|
||||
}
|
||||
.join("coop");
|
||||
}
|
||||
|
||||
home_dir().join(".config").join("coop")
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the support directory used by Coop.
|
||||
pub fn support_dir() -> &'static PathBuf {
|
||||
static SUPPORT_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
SUPPORT_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
return home_dir().join("Library/Application Support/Coop");
|
||||
}
|
||||
|
||||
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
return if let Ok(flatpak_xdg_data) = std::env::var("FLATPAK_XDG_DATA_HOME") {
|
||||
flatpak_xdg_data.into()
|
||||
} else {
|
||||
dirs::data_local_dir().expect("failed to determine XDG_DATA_HOME directory")
|
||||
}
|
||||
.join("coop");
|
||||
}
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
return dirs::data_local_dir()
|
||||
.expect("failed to determine LocalAppData directory")
|
||||
.join("coop");
|
||||
}
|
||||
|
||||
config_dir().clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the `nostr` file.
|
||||
pub fn nostr_file() -> &'static PathBuf {
|
||||
static NOSTR_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
NOSTR_FILE.get_or_init(|| support_dir().join("nostr"))
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
[package]
|
||||
name = "ui"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
gpui.workspace = true
|
||||
smol.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -20,3 +23,5 @@ unicode-segmentation = "1.12.0"
|
||||
uuid = "1.10"
|
||||
once_cell = "1.19.0"
|
||||
image = "0.25.1"
|
||||
linkify = "0.10.0"
|
||||
emojis.workspace = true
|
||||
|
||||
@@ -348,8 +348,7 @@ impl RenderOnce for Button {
|
||||
Size::Size(px) => this.size(px),
|
||||
Size::XSmall => this.size_5(),
|
||||
Size::Small => this.size_6(),
|
||||
Size::Medium => this.size_8(),
|
||||
Size::Large => this.size_9(),
|
||||
_ => this.size_9(),
|
||||
}
|
||||
} else {
|
||||
// Normal Button
|
||||
@@ -357,7 +356,8 @@ impl RenderOnce for Button {
|
||||
Size::Size(size) => this.px(size * 0.2),
|
||||
Size::XSmall => this.h_6().px_0p5(),
|
||||
Size::Small => this.h_7().px_2(),
|
||||
_ => this.h_8().px_3(),
|
||||
Size::Large => this.h_10().px_3(),
|
||||
_ => this.h_9().px_2(),
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -437,10 +437,11 @@ impl RenderOnce for Button {
|
||||
.id("label")
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.gap_0p5().text_xs(),
|
||||
Size::Small => this.gap_1().text_xs(),
|
||||
_ => this.gap_2().text_xs(),
|
||||
Size::XSmall => this.gap_0p5(),
|
||||
Size::Small => this.gap_1(),
|
||||
_ => this.gap_2().font_medium(),
|
||||
})
|
||||
.when(!self.loading, |this| {
|
||||
this.when_some(self.icon, |this, icon| {
|
||||
@@ -450,7 +451,6 @@ impl RenderOnce for Button {
|
||||
.when(self.loading, |this| {
|
||||
this.child(
|
||||
Indicator::new()
|
||||
.with_size(self.size)
|
||||
.when_some(self.loading_icon, |this, icon| this.icon(icon)),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -115,9 +115,8 @@ impl Element for Clipboard {
|
||||
*copied.borrow_mut() = true;
|
||||
|
||||
let copied = copied.clone();
|
||||
cx.spawn(|cx| async move {
|
||||
cx.spawn(async move |cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
*copied.borrow_mut() = false;
|
||||
})
|
||||
.detach();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::theme::{scale::ColorScaleStep, ActiveTheme};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, px, Axis, Div, Hsla, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled,
|
||||
};
|
||||
|
||||
use crate::theme::{scale::ColorScaleStep, ActiveTheme};
|
||||
|
||||
/// A divider that can be either vertical or horizontal.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Divider {
|
||||
|
||||
@@ -574,8 +574,8 @@ impl DockArea {
|
||||
window,
|
||||
move |_, _, event, window, cx| {
|
||||
if let PanelEvent::LayoutChanged = event {
|
||||
cx.spawn_in(window, |view, mut window| async move {
|
||||
_ = view.update_in(&mut window, |view, window, cx| {
|
||||
cx.spawn_in(window, async move |view, window| {
|
||||
_ = view.update_in(window, |view, window, cx| {
|
||||
view.update_toggle_button_tab_panels(window, cx)
|
||||
});
|
||||
})
|
||||
@@ -609,8 +609,8 @@ impl DockArea {
|
||||
move |_, panel, event, window, cx| match event {
|
||||
PanelEvent::ZoomIn => {
|
||||
let panel = panel.clone();
|
||||
cx.spawn_in(window, |view, mut window| async move {
|
||||
_ = view.update_in(&mut window, |view, window, cx| {
|
||||
cx.spawn_in(window, async move |view, window| {
|
||||
_ = view.update_in(window, |view, window, cx| {
|
||||
view.set_zoomed_in(panel, window, cx);
|
||||
cx.notify();
|
||||
});
|
||||
@@ -618,15 +618,15 @@ impl DockArea {
|
||||
.detach();
|
||||
}
|
||||
PanelEvent::ZoomOut => cx
|
||||
.spawn_in(window, |view, mut window| async move {
|
||||
_ = view.update_in(&mut window, |view, window, cx| {
|
||||
.spawn_in(window, async move |view, window| {
|
||||
_ = view.update_in(window, |view, window, cx| {
|
||||
view.set_zoomed_out(window, cx);
|
||||
});
|
||||
})
|
||||
.detach(),
|
||||
PanelEvent::LayoutChanged => {
|
||||
cx.spawn_in(window, |view, mut window| async move {
|
||||
_ = view.update_in(&mut window, |view, window, cx| {
|
||||
cx.spawn_in(window, async move |view, window| {
|
||||
_ = view.update_in(window, |view, window, cx| {
|
||||
view.update_toggle_button_tab_panels(window, cx)
|
||||
});
|
||||
})
|
||||
|
||||
@@ -181,7 +181,7 @@ impl TabPanel {
|
||||
self.focus_active_panel(window, cx);
|
||||
|
||||
// Sync the active state to all panels
|
||||
cx.spawn(|view, cx| async move {
|
||||
cx.spawn(async move |view, cx| {
|
||||
_ = cx.update(|cx| {
|
||||
_ = view.update(cx, |view, cx| {
|
||||
if let Some(last_active) = view.panels.get(last_active_ix) {
|
||||
@@ -255,7 +255,7 @@ impl TabPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
cx.spawn_in(window, |view, mut cx| async move {
|
||||
cx.spawn_in(window, async move |view, cx| {
|
||||
cx.update(|window, cx| {
|
||||
view.update(cx, |view, cx| {
|
||||
view.will_split_placement = Some(placement);
|
||||
@@ -407,19 +407,19 @@ impl TabPanel {
|
||||
let build_popup_menu = move |this, cx: &App| view.read(cx).popup_menu(this, cx);
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.gap_1()
|
||||
.occlude()
|
||||
.items_center()
|
||||
.children(
|
||||
self.toolbar_buttons(window, cx)
|
||||
.into_iter()
|
||||
.map(|btn| btn.xsmall().ghost()),
|
||||
.map(|btn| btn.small().ghost()),
|
||||
)
|
||||
.when(self.is_zoomed, |this| {
|
||||
this.child(
|
||||
Button::new("zoom")
|
||||
.icon(IconName::Minimize)
|
||||
.xsmall()
|
||||
.icon(IconName::ArrowIn)
|
||||
.small()
|
||||
.ghost()
|
||||
.tooltip("Zoom Out")
|
||||
.on_click(cx.listener(|view, _, window, cx| {
|
||||
@@ -430,7 +430,7 @@ impl TabPanel {
|
||||
.child(
|
||||
Button::new("menu")
|
||||
.icon(IconName::Ellipsis)
|
||||
.xsmall()
|
||||
.small()
|
||||
.ghost()
|
||||
.popup_menu({
|
||||
let zoomable = state.zoomable;
|
||||
@@ -451,7 +451,7 @@ impl TabPanel {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_dock_toggle_button(
|
||||
fn _render_dock_toggle_button(
|
||||
&self,
|
||||
placement: DockPlacement,
|
||||
_window: &mut Window,
|
||||
@@ -517,7 +517,7 @@ impl TabPanel {
|
||||
Some(
|
||||
Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
|
||||
.icon(icon)
|
||||
.xsmall()
|
||||
.small()
|
||||
.ghost()
|
||||
.tooltip(match is_open {
|
||||
true => "Collapse",
|
||||
@@ -547,9 +547,6 @@ impl TabPanel {
|
||||
};
|
||||
|
||||
let panel_style = dock_area.read(cx).panel_style;
|
||||
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, window, cx);
|
||||
let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, window, cx);
|
||||
let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, window, cx);
|
||||
|
||||
if self.panels.len() == 1 && panel_style == PanelStyle::Default {
|
||||
let panel = self.panels.first().unwrap();
|
||||
@@ -565,21 +562,6 @@ impl TabPanel {
|
||||
.h(px(30.))
|
||||
.py_2()
|
||||
.px_3()
|
||||
.when(left_dock_button.is_some(), |this| this.pl_2())
|
||||
.when(right_dock_button.is_some(), |this| this.pr_2())
|
||||
.when(
|
||||
left_dock_button.is_some() || bottom_dock_button.is_some(),
|
||||
|this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.mr_1()
|
||||
.gap_1()
|
||||
.children(left_dock_button)
|
||||
.children(bottom_dock_button),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("tab")
|
||||
@@ -612,8 +594,7 @@ impl TabPanel {
|
||||
.flex_shrink_0()
|
||||
.ml_1()
|
||||
.gap_1()
|
||||
.child(self.render_toolbar(state, window, cx))
|
||||
.children(right_dock_button),
|
||||
.child(self.render_toolbar(state, window, cx)),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
@@ -622,22 +603,6 @@ impl TabPanel {
|
||||
|
||||
TabBar::new("tab-bar")
|
||||
.track_scroll(self.tab_bar_scroll_handle.clone())
|
||||
.when(
|
||||
left_dock_button.is_some() || bottom_dock_button.is_some(),
|
||||
|this| {
|
||||
this.prefix(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.top_0()
|
||||
// Right -1 for avoid border overlap with the first tab
|
||||
.right(-px(1.))
|
||||
.h_full()
|
||||
.px_2()
|
||||
.children(left_dock_button)
|
||||
.children(bottom_dock_button),
|
||||
)
|
||||
},
|
||||
)
|
||||
.children(self.panels.iter().enumerate().filter_map(|(ix, panel)| {
|
||||
let mut active = state.active_panel.as_ref() == Some(panel);
|
||||
let disabled = self.is_collapsed;
|
||||
@@ -723,8 +688,7 @@ impl TabPanel {
|
||||
.h_full()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(self.render_toolbar(state, window, cx))
|
||||
.when_some(right_dock_button, |this, btn| this.child(btn)),
|
||||
.child(self.render_toolbar(state, window, cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -986,7 +950,7 @@ impl TabPanel {
|
||||
});
|
||||
}
|
||||
|
||||
cx.spawn_in(window, |_, mut cx| async move {
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
cx.update(|window, cx| {
|
||||
tab_panel.update(cx, |view, cx| view.remove_self_if_empty(window, cx))
|
||||
})
|
||||
@@ -1021,9 +985,9 @@ impl TabPanel {
|
||||
|
||||
self.is_zoomed = !self.is_zoomed;
|
||||
|
||||
cx.spawn(|view, cx| {
|
||||
cx.spawn({
|
||||
let is_zoomed = self.is_zoomed;
|
||||
async move {
|
||||
async move |view, cx| {
|
||||
_ = cx.update(|cx| {
|
||||
_ = view.update(cx, |view, cx| {
|
||||
view.set_zoomed(is_zoomed, cx);
|
||||
|
||||
@@ -661,9 +661,9 @@ where
|
||||
Some(icon) => icon,
|
||||
None => {
|
||||
if self.open {
|
||||
IconName::ChevronUp
|
||||
IconName::CaretUp
|
||||
} else {
|
||||
IconName::ChevronDown
|
||||
IconName::CaretDown
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
136
crates/ui/src/emoji_picker.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
div, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Corner, Element,
|
||||
InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement, Styled, WeakEntity, Window,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::TextInput,
|
||||
popover::{Popover, PopoverContent},
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Icon,
|
||||
};
|
||||
|
||||
#[derive(PartialEq, Clone, Debug, Deserialize)]
|
||||
pub struct EmitEmoji(pub SharedString);
|
||||
|
||||
impl_internal_actions!(emoji, [EmitEmoji]);
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct EmojiPicker {
|
||||
icon: Option<Icon>,
|
||||
anchor: Option<Corner>,
|
||||
input: WeakEntity<TextInput>,
|
||||
emojis: Rc<Vec<SharedString>>,
|
||||
}
|
||||
|
||||
impl EmojiPicker {
|
||||
pub fn new(input: WeakEntity<TextInput>) -> Self {
|
||||
let mut emojis: Vec<SharedString> = vec![];
|
||||
|
||||
emojis.extend(
|
||||
emojis::Group::SmileysAndEmotion
|
||||
.emojis()
|
||||
.map(|e| SharedString::from(e.as_str()))
|
||||
.collect::<Vec<SharedString>>(),
|
||||
);
|
||||
|
||||
emojis.extend(
|
||||
emojis::Group::Symbols
|
||||
.emojis()
|
||||
.map(|e| SharedString::from(e.as_str()))
|
||||
.collect::<Vec<SharedString>>(),
|
||||
);
|
||||
|
||||
Self {
|
||||
input,
|
||||
emojis: emojis.into(),
|
||||
anchor: None,
|
||||
icon: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn anchor(mut self, corner: Corner) -> Self {
|
||||
self.anchor = Some(corner);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for EmojiPicker {
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
Popover::new("emoji-picker")
|
||||
.map(|this| {
|
||||
if let Some(corner) = self.anchor {
|
||||
this.anchor(corner)
|
||||
} else {
|
||||
this.anchor(gpui::Corner::BottomLeft)
|
||||
}
|
||||
})
|
||||
.trigger(
|
||||
Button::new("emoji-trigger")
|
||||
.when_some(self.icon, |this, icon| this.icon(icon))
|
||||
.ghost(),
|
||||
)
|
||||
.content(move |window, cx| {
|
||||
let emojis = self.emojis.clone();
|
||||
let input = self.input.clone();
|
||||
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, move |_window, cx| {
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.children(emojis.iter().map(|e| {
|
||||
div()
|
||||
.id(e.clone())
|
||||
.flex_auto()
|
||||
.size_10()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(e.clone())
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
})
|
||||
.on_click({
|
||||
let item = e.clone();
|
||||
let input = input.upgrade();
|
||||
|
||||
move |_, window, cx| {
|
||||
if let Some(input) = input.as_ref() {
|
||||
input.update(cx, |this, cx| {
|
||||
let current = this.text();
|
||||
let new_text = if current.is_empty() {
|
||||
format!("{}", item)
|
||||
} else if current.ends_with(" ") {
|
||||
format!("{}{}", current, item)
|
||||
} else {
|
||||
format!("{} {}", current, item)
|
||||
};
|
||||
this.set_text(new_text, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
.into_any()
|
||||
})
|
||||
.scrollable()
|
||||
.max_h(px(300.))
|
||||
.max_w(px(300.))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,57 +10,39 @@ use gpui::{
|
||||
|
||||
#[derive(IntoElement, Clone)]
|
||||
pub enum IconName {
|
||||
ALargeSmall,
|
||||
AddressBook,
|
||||
ArrowIn,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ArrowUpCircle,
|
||||
Asterisk,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Calendar,
|
||||
ChartPie,
|
||||
CaretUp,
|
||||
CaretDown,
|
||||
CaretDownFill,
|
||||
CaretRight,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronDownSmall,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
ChevronsUpDown,
|
||||
CircleCheck,
|
||||
CircleUser,
|
||||
CircleX,
|
||||
CheckCircle,
|
||||
CheckCircleFill,
|
||||
Close,
|
||||
CloseCircle,
|
||||
CloseCircleFill,
|
||||
Copy,
|
||||
ComposeFill,
|
||||
Dash,
|
||||
Delete,
|
||||
EditFill,
|
||||
Ellipsis,
|
||||
EllipsisVertical,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Frame,
|
||||
GalleryVerticalEnd,
|
||||
GitHub,
|
||||
Globe,
|
||||
Group,
|
||||
GroupFill,
|
||||
Heart,
|
||||
HeartOff,
|
||||
EmojiFill,
|
||||
Folder,
|
||||
FolderFill,
|
||||
Inbox,
|
||||
Info,
|
||||
LayoutDashboard,
|
||||
Loader,
|
||||
LoaderCircle,
|
||||
Map,
|
||||
Maximize,
|
||||
MailboxFill,
|
||||
Menu,
|
||||
Minimize,
|
||||
Minus,
|
||||
Moon,
|
||||
Relays,
|
||||
Palette,
|
||||
PanelBottom,
|
||||
PanelBottomOpen,
|
||||
@@ -71,20 +53,19 @@ pub enum IconName {
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Plus,
|
||||
PlusCircleFill,
|
||||
Relays,
|
||||
ResizeCorner,
|
||||
Search,
|
||||
Settings,
|
||||
Settings2,
|
||||
SortAscending,
|
||||
SortDescending,
|
||||
SquareTerminal,
|
||||
Star,
|
||||
StarOff,
|
||||
Sun,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
TriangleAlert,
|
||||
Upload,
|
||||
ResizeCorner,
|
||||
UsersThreeFill,
|
||||
WindowClose,
|
||||
WindowMaximize,
|
||||
WindowMinimize,
|
||||
@@ -94,57 +75,39 @@ pub enum IconName {
|
||||
impl IconName {
|
||||
pub fn path(self) -> SharedString {
|
||||
match self {
|
||||
Self::ALargeSmall => "icons/a-large-small.svg",
|
||||
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::Asterisk => "icons/asterisk.svg",
|
||||
Self::Bell => "icons/bell.svg",
|
||||
Self::BookOpen => "icons/book-open.svg",
|
||||
Self::Bot => "icons/bot.svg",
|
||||
Self::Calendar => "icons/calendar.svg",
|
||||
Self::ChartPie => "icons/chart-pie.svg",
|
||||
Self::CaretRight => "icons/caret-right.svg",
|
||||
Self::CaretUp => "icons/caret-up.svg",
|
||||
Self::CaretDown => "icons/caret-down.svg",
|
||||
Self::CaretDownFill => "icons/caret-down-fill.svg",
|
||||
Self::Check => "icons/check.svg",
|
||||
Self::ChevronDown => "icons/chevron-down.svg",
|
||||
Self::ChevronDownSmall => "icons/chevron-down-small.svg",
|
||||
Self::ChevronLeft => "icons/chevron-left.svg",
|
||||
Self::ChevronRight => "icons/chevron-right.svg",
|
||||
Self::ChevronUp => "icons/chevron-up.svg",
|
||||
Self::ChevronsUpDown => "icons/chevrons-up-down.svg",
|
||||
Self::CircleCheck => "icons/circle-check.svg",
|
||||
Self::CircleUser => "icons/circle-user.svg",
|
||||
Self::CircleX => "icons/circle-x.svg",
|
||||
Self::CheckCircle => "icons/check-circle.svg",
|
||||
Self::CheckCircleFill => "icons/check-circle-fill.svg",
|
||||
Self::Close => "icons/close.svg",
|
||||
Self::CloseCircle => "icons/close-circle.svg",
|
||||
Self::CloseCircleFill => "icons/close-circle-fill.svg",
|
||||
Self::Copy => "icons/copy.svg",
|
||||
Self::ComposeFill => "icons/compose-fill.svg",
|
||||
Self::Dash => "icons/dash.svg",
|
||||
Self::Delete => "icons/delete.svg",
|
||||
Self::EditFill => "icons/edit-fill.svg",
|
||||
Self::Ellipsis => "icons/ellipsis.svg",
|
||||
Self::EllipsisVertical => "icons/ellipsis-vertical.svg",
|
||||
Self::Eye => "icons/eye.svg",
|
||||
Self::EmojiFill => "icons/emoji-fill.svg",
|
||||
Self::EyeOff => "icons/eye-off.svg",
|
||||
Self::Frame => "icons/frame.svg",
|
||||
Self::GalleryVerticalEnd => "icons/gallery-vertical-end.svg",
|
||||
Self::GitHub => "icons/github.svg",
|
||||
Self::Globe => "icons/globe.svg",
|
||||
Self::Group => "icons/group.svg",
|
||||
Self::GroupFill => "icons/group-fill.svg",
|
||||
Self::Heart => "icons/heart.svg",
|
||||
Self::HeartOff => "icons/heart-off.svg",
|
||||
Self::Folder => "icons/folder.svg",
|
||||
Self::FolderFill => "icons/folder-fill.svg",
|
||||
Self::Inbox => "icons/inbox.svg",
|
||||
Self::Info => "icons/info.svg",
|
||||
Self::LayoutDashboard => "icons/layout-dashboard.svg",
|
||||
Self::Loader => "icons/loader.svg",
|
||||
Self::LoaderCircle => "icons/loader-circle.svg",
|
||||
Self::Map => "icons/map.svg",
|
||||
Self::Maximize => "icons/maximize.svg",
|
||||
Self::MailboxFill => "icons/mailbox-fill.svg",
|
||||
Self::Menu => "icons/menu.svg",
|
||||
Self::Minimize => "icons/minimize.svg",
|
||||
Self::Minus => "icons/minus.svg",
|
||||
Self::Moon => "icons/moon.svg",
|
||||
Self::Relays => "icons/relays.svg",
|
||||
Self::Palette => "icons/palette.svg",
|
||||
Self::PanelBottom => "icons/panel-bottom.svg",
|
||||
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
|
||||
@@ -155,20 +118,19 @@ impl IconName {
|
||||
Self::PanelRightClose => "icons/panel-right-close.svg",
|
||||
Self::PanelRightOpen => "icons/panel-right-open.svg",
|
||||
Self::Plus => "icons/plus.svg",
|
||||
Self::PlusCircleFill => "icons/plus-circle-fill.svg",
|
||||
Self::Relays => "icons/relays.svg",
|
||||
Self::ResizeCorner => "icons/resize-corner.svg",
|
||||
Self::Search => "icons/search.svg",
|
||||
Self::Settings => "icons/settings.svg",
|
||||
Self::Settings2 => "icons/settings-2.svg",
|
||||
Self::SortAscending => "icons/sort-ascending.svg",
|
||||
Self::SortDescending => "icons/sort-descending.svg",
|
||||
Self::SquareTerminal => "icons/square-terminal.svg",
|
||||
Self::Star => "icons/star.svg",
|
||||
Self::StarOff => "icons/star-off.svg",
|
||||
Self::Sun => "icons/sun.svg",
|
||||
Self::ThumbsDown => "icons/thumbs-down.svg",
|
||||
Self::ThumbsUp => "icons/thumbs-up.svg",
|
||||
Self::TriangleAlert => "icons/triangle-alert.svg",
|
||||
Self::Upload => "icons/upload.svg",
|
||||
Self::ResizeCorner => "icons/resize-corner.svg",
|
||||
Self::UsersThreeFill => "icons/users-three-fill.svg",
|
||||
Self::WindowClose => "icons/window-close.svg",
|
||||
Self::WindowMaximize => "icons/window-maximize.svg",
|
||||
Self::WindowMinimize => "icons/window-minimize.svg",
|
||||
@@ -303,8 +265,8 @@ impl RenderOnce for Icon {
|
||||
.when_some(self.size, |this, size| match size {
|
||||
Size::Size(px) => this.size(px),
|
||||
Size::XSmall => this.size_3(),
|
||||
Size::Small => this.size_3p5(),
|
||||
Size::Medium => this.size_4(),
|
||||
Size::Small => this.size_4(),
|
||||
Size::Medium => this.size_5(),
|
||||
Size::Large => this.size_6(),
|
||||
})
|
||||
.path(self.path)
|
||||
@@ -333,8 +295,8 @@ impl Render for Icon {
|
||||
.when_some(self.size, |this, size| match size {
|
||||
Size::Size(px) => this.size(px),
|
||||
Size::XSmall => this.size_3(),
|
||||
Size::Small => this.size_3p5(),
|
||||
Size::Medium => this.size_4(),
|
||||
Size::Small => this.size_4(),
|
||||
Size::Medium => this.size_5(),
|
||||
Size::Large => this.size_6(),
|
||||
})
|
||||
.path(self.path.clone())
|
||||
|
||||
@@ -16,7 +16,7 @@ pub struct Indicator {
|
||||
impl Indicator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
size: Size::Medium,
|
||||
size: Size::Small,
|
||||
speed: Duration::from_secs_f64(0.8),
|
||||
icon: Icon::new(IconName::Loader),
|
||||
color: None,
|
||||
|
||||
@@ -50,10 +50,10 @@ impl BlinkCursor {
|
||||
|
||||
// Schedule the next blink
|
||||
let epoch = self.next_epoch();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
cx.spawn(async move |this, cx| {
|
||||
Timer::after(INTERVAL).await;
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(&mut cx, |this, cx| this.blink(epoch, cx)).ok();
|
||||
this.update(cx, |this, cx| this.blink(epoch, cx)).ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
@@ -71,11 +71,11 @@ impl BlinkCursor {
|
||||
|
||||
// delay 500ms to start the blinking
|
||||
let epoch = self.next_epoch();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
cx.spawn(async move |this, cx| {
|
||||
Timer::after(PAUSE_DELAY).await;
|
||||
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.paused = false;
|
||||
this.blink(epoch, cx);
|
||||
})
|
||||
|
||||