Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69ca199a7d | ||
|
|
83a440d893 | ||
|
|
77b2a5f3cd | ||
|
|
b0121f8df2 | ||
|
|
bfa34959cf | ||
|
|
4d0e2f5050 | ||
|
|
0824b1825b | ||
|
|
3212951ed4 | ||
|
|
ceb69660be | ||
|
|
8f35ae92b7 | ||
|
|
30da985ac1 |
@@ -20,9 +20,6 @@ jobs:
|
|||||||
- platform: windows-arm64
|
- platform: windows-arm64
|
||||||
os: windows-11-arm
|
os: windows-11-arm
|
||||||
target: aarch64-pc-windows-msvc
|
target: aarch64-pc-windows-msvc
|
||||||
- platform: macos-x64
|
|
||||||
os: macos-15-intel
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
- platform: macos-arm64
|
- platform: macos-arm64
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
|
|||||||
@@ -30,3 +30,25 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test --verbose
|
run: cargo test --verbose
|
||||||
|
|
||||||
|
check-wasm:
|
||||||
|
name: Check web target
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust nightly
|
||||||
|
uses: dtolnay/rust-toolchain@nightly
|
||||||
|
with:
|
||||||
|
targets: wasm32-unknown-unknown
|
||||||
|
components: llvm-tools-preview
|
||||||
|
|
||||||
|
- name: Install wasm C toolchain
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y clang llvm
|
||||||
|
|
||||||
|
- name: Check web target
|
||||||
|
env:
|
||||||
|
CC_wasm32_unknown_unknown: clang
|
||||||
|
AR_wasm32_unknown_unknown: llvm-ar
|
||||||
|
run: cargo +nightly check -p coop_web --target wasm32-unknown-unknown
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ dist/
|
|||||||
vendor/
|
vendor/
|
||||||
wasm/
|
wasm/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.wrangler/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "2.0.0-alpha"
|
version = "2.0.0-alpha3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -32,7 +32,9 @@ nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
|||||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
nostr-browser-signer = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
nostr-sqlite = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
|
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
|
||||||
|
|
||||||
# Crypto (NIP-17 encrypted file messages)
|
# Crypto (NIP-17 encrypted file messages)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1086 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.25 13L12 16.25L8.75 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 7.75V15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 571 B |
@@ -1,3 +1,16 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M10 5.75L3.75 12L10 18.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 12H20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M10 5.75L3.75 12L10 18.25"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4.5 12H20.25"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 319 B After Width: | Height: | Size: 418 B |
@@ -1,13 +1 @@
|
|||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M9.25 9.75C7.317 9.75 5.75 8.183 5.75 6.25C5.75 4.317 7.317 2.75 9.25 2.75C11.183 2.75 12.75 4.317 12.75 6.25C12.75 8.183 11.183 9.75 9.25 9.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 2.75C16.933 2.75 18.5 4.317 18.5 6.25C18.5 8.183 16.933 9.75 15 9.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.24806 12.75C5.46395 12.75 2.27489 15.1517 1.30259 18.2496C0.971822 19.3035 1.89349 20.2187 2.99806 20.2188H15.4981C16.6026 20.2188 17.5243 19.3035 17.1935 18.2496C16.2212 15.1517 13.0322 12.75 9.24806 12.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.75 20.25H21.25C22.3546 20.25 23.2748 19.3347 22.9405 18.2819C22.2598 16.1389 20.512 14.31 18.25 13.5088" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />
|
|
||||||
</svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 310 B After Width: | Height: | Size: 1014 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M5.75 9.5L12 15.75L18.25 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 209 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M9.5 18.25L15.75 12L9.5 5.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 209 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.9231L10.5625 15.75L15.25 8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 341 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75" stroke="black" stroke-width="1.5" stroke-linecap="round"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 191 B After Width: | Height: | Size: 201 B |
@@ -1,14 +1,3 @@
|
|||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><ellipse cx="12" cy="12" rx="3.5" ry="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 9.25H20.5" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 14.75H20.5" stroke="currentColor" stroke-width="1.5"/>
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 351 B After Width: | Height: | Size: 377 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M2.75 21.25L21.25 21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.75 21.25V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H17.25C18.3546 2.75 19.25 3.64543 19.25 4.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.25H8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 522 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M19 1.75V8.25M15.75 5H22.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M10.75 9.9C10.75 11.0046 10.0784 11.75 9.25 11.75C8.42157 11.75 7.75 11.0046 7.75 9.9C7.75 8.79543 8.42157 8 9.25 8C10.0784 8 10.75 8.79543 10.75 9.9Z" fill="currentColor"/><path d="M16.25 9.9C16.25 11.0046 15.5784 11.75 14.75 11.75C13.9216 11.75 13.25 11.0046 13.25 9.9C13.25 8.79543 13.9216 8 14.75 8C15.5784 8 16.25 8.79543 16.25 9.9Z" fill="currentColor"/><path d="M16.1123 14.8493C16.1942 14.7105 16.2249 14.545 16.192 14.3857C16.1592 14.2263 16.0665 14.0867 15.933 13.9968C15.7996 13.9069 15.6354 13.8733 15.4754 13.9028C15.3154 13.9321 15.1736 14.0226 15.0757 14.1507C15.0008 14.2469 14.9237 14.3367 14.8415 14.4241C14.1096 15.2083 13.061 15.628 12.0035 15.625C10.946 15.6265 9.8972 15.2055 9.16254 14.4222C9.08002 14.3348 9.00261 14.2451 8.92738 14.1491C8.8291 14.0214 8.68699 13.9313 8.52686 13.9024C8.36679 13.8735 8.20268 13.9076 8.06954 13.9979C7.9364 14.0882 7.84406 14.2281 7.81174 14.3875C7.77938 14.547 7.81054 14.7123 7.89293 14.8509C7.97731 14.99 8.06686 15.1223 8.16553 15.2526C9.04297 16.4311 10.5292 17.1343 12.0024 17.125C13.4754 17.1367 14.965 16.4342 15.8405 15.2521C15.939 15.1215 16.0282 14.9888 16.1123 14.8493Z" fill="currentColor"/><path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="8.75" r="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="4" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="20" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.25 16.625V16.5C7.25 13.8766 9.37665 11.75 12 11.75C14.6234 11.75 16.75 13.8766 16.75 16.5V16.625C16.75 17.5225 16.0225 18.25 15.125 18.25H8.875C7.97754 18.25 7.25 17.5225 7.25 16.625Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.25 17.2602H2.75C1.64543 17.2602 0.706551 16.3538 0.919944 15.2701C1.25877 13.5493 2.15049 12.3257 4 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.75 17.2601H21.25C22.3546 17.2601 23.2935 16.3538 23.08 15.27C22.7412 13.5493 21.8495 12.3257 20 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M11.75 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V15.25C21.25 16.3546 20.3546 17.25 19.25 17.25H11.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.75 6.75H4.75C3.64543 6.75 2.75 7.64543 2.75 8.75V15.25C2.75 16.3546 3.64543 17.25 4.75 17.25H5.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.75 3.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 604 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M4.75 10.9853V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H17.25C18.3546 2.75 19.25 3.64543 19.25 4.75V10.9853M9.75 7.75H14.25M12.617 13.5499L19.9415 11.1744C20.5875 10.9649 21.25 11.4465 21.25 12.1256V18.25C21.25 19.3546 20.3546 20.25 19.25 20.25H4.75C3.64543 20.25 2.75 19.3546 2.75 18.25V12.1256C2.75 11.4465 3.41249 10.9649 4.0585 11.1744L11.383 13.5499C11.784 13.68 12.216 13.68 12.617 13.5499Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 576 B |
@@ -1,14 +1,3 @@
|
|||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path d="M9.75027 5.52371L10.7168 4.55722C13.1264 2.14759 17.0332 2.14759 19.4428 4.55722C21.8524 6.96684 21.8524 10.8736 19.4428 13.2832L18.4742 14.2519M5.52886 9.74513L4.55722 10.7168C2.14759 13.1264 2.1476 17.0332 4.55722 19.4428C6.96684 21.8524 10.8736 21.8524 13.2832 19.4428L14.2478 18.4782M9.5 14.5L14.5 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 359 B After Width: | Height: | Size: 462 B |
@@ -1,14 +1,3 @@
|
|||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path d="M12 8.75003C11.1716 8.75003 10.5 9.4216 10.5 10.25C10.5 11.0785 11.1716 11.75 12 11.75C12.8284 11.75 13.5 11.0785 13.5 10.25C13.5 9.4216 12.8284 8.75003 12 8.75003ZM12 8.75003V14.75M20.25 11.9124V6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 309 B After Width: | Height: | Size: 626 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 520 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M2.66936 5.12886C2.12122 3.64104 3.6759 2.24953 5.09409 2.95862L20.0468 10.435C21.3366 11.0799 21.3366 12.9205 20.0468 13.5655L5.09409 21.0418C3.67589 21.7509 2.12122 20.3594 2.66936 18.8715L4.92467 12.75H9.25021C9.66442 12.75 10.0002 12.4142 10.0002 12C10.0002 11.5858 9.66442 11.25 9.25021 11.25H4.92452L2.66936 5.12886Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 435 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12.2126 10.7642L7.07919 15.8829C6.51216 16.4483 6.51216 17.365 7.07919 17.9304C7.64621 18.4958 8.56554 18.4958 9.13257 17.9304L19.3995 7.69303C19.9664 7.12769 20.25 6.38673 20.25 5.64575C20.25 4.90465 19.9665 4.16353 19.3995 3.59809C18.8324 3.03271 18.0893 2.75001 17.3461 2.75C16.6029 2.74999 15.8597 3.03269 15.2927 3.59809L5.02581 13.8354C4.17522 14.6836 3.74995 15.7952 3.75 16.9068C3.75005 18.0183 4.17532 19.1298 5.02581 19.9779C5.87635 20.826 6.99113 21.25 8.10591 21.25C9.22067 21.25 10.3354 20.8259 11.1859 19.9779L16.3194 14.8592" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 697 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/><path d="M12 8.75V15.25M8.75 12H15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>
|
||||||
|
After Width: | Height: | Size: 624 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M12 5.25V12M12 12V18.75M12 12H5.25M12 12H18.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
<path d="M12 6.75V12M12 12V17.25M12 12H6.75M12 12H17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 203 B After Width: | Height: | Size: 203 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M17.75 2.75H6.25C5.14543 2.75 4.25 3.64543 4.25 4.75V19.25C4.25 20.3546 5.14543 21.25 6.25 21.25H17.75C18.8546 21.25 19.75 20.3546 19.75 19.25V4.75C19.75 3.64543 18.8546 2.75 17.75 2.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12.25" r="2.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M16 21C16 18.7909 14.2091 17 12 17C9.79086 17 8 18.7909 8 21" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M9.75 6.25H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 696 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="9.25" r="1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 21.25L11.75 9.25H12.25L16.25 21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.5 17.75H14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75693 12.7501C6.08102 10.7234 6.08103 7.77679 7.75693 5.75M16.2431 5.75C17.919 7.77679 17.919 10.7234 16.2431 12.7501" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.06494 2.7574C1.64285 6.40823 1.64502 12.1018 5.07145 15.75M18.9281 2.75C22.3572 6.40053 22.3573 12.0993 18.9285 15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 899 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M20.25 20.25L16.1265 16.1265M16.1265 16.1265C17.4385 14.8145 18.25 13.002 18.25 11C18.25 6.99594 15.0041 3.75 11 3.75C6.99594 3.75 3.75 6.99594 3.75 11C3.75 15.0041 6.99594 18.25 11 18.25C13.002 18.25 14.8145 17.4385 16.1265 16.1265Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M20 20L16.1265 16.1265M16.1265 16.1265C17.4385 14.8145 18.25 13.002 18.25 11C18.25 6.99594 15.0041 3.75 11 3.75C6.99594 3.75 3.75 6.99594 3.75 11C3.75 15.0041 6.99594 18.25 11 18.25C13.002 18.25 14.8145 17.4385 16.1265 16.1265Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 404 B After Width: | Height: | Size: 408 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M13 19.25H5.95C4.82989 19.25 4.26984 19.25 3.84202 19.032C3.46569 18.8403 3.15973 18.5343 2.96799 18.158C2.75 17.7302 2.75 17.1701 2.75 16.05V7.95C2.75 6.82989 2.75 6.26984 2.96799 5.84202C3.15973 5.46569 3.46569 5.15973 3.84202 4.96799C4.26984 4.75 4.8299 4.75 5.95 4.75H18.05C19.1701 4.75 19.7302 4.75 20.158 4.96799C20.5343 5.15973 20.8403 5.46569 21.032 5.84202C21.25 6.26984 21.25 6.8299 21.25 7.95V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3 5.63635L10.9761 10.3898C11.6069 10.7657 12.3931 10.7657 13.0239 10.3898L21 5.63635" stroke="currentColor" stroke-width="1.5"/><path d="M21.8148 15.375L21.1669 15.7491M16.1856 18.625L16.8335 18.2509M21.8147 18.625L21.1669 18.251M16.1855 15.375L16.8335 15.7491M19.0002 20.25L19.0002 19.4375M19.0002 13.75V14.5625M21.1669 17C21.1669 16.6053 21.0613 16.2352 20.8769 15.9165C20.5022 15.269 19.8021 14.8333 19.0002 14.8333C18.1983 14.8333 17.4981 15.269 17.1235 15.9165C16.9391 16.2352 16.8335 16.6053 16.8335 17C16.8335 17.3947 16.9391 17.7648 17.1235 18.0835C17.4982 18.731 18.1983 19.1667 19.0002 19.1667C19.8021 19.1667 20.5022 18.731 20.8769 18.0835C21.0613 17.7648 21.1669 17.3947 21.1669 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,15 +1 @@
|
|||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25V5.75C3.75 4.64543 4.64543 3.75 5.75 3.75H18.25C19.3546 3.75 20.25 4.64543 20.25 5.75V11.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3.75 16.25L6.58579 13.4142C7.36683 12.6332 8.63317 12.6332 9.41421 13.4142L12 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M14.5 11.5C15.6046 11.5 16.5 10.6046 16.5 9.5C16.5 8.39543 15.6046 7.5 14.5 7.5C13.3954 7.5 12.5 8.39543 12.5 9.5C12.5 10.6046 13.3954 11.5 14.5 11.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M15.75 18.5H21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M18.5 15.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M12 3v12" />
|
|
||||||
<path d="m17 8-5-5-5 5" />
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
||||||
</svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 849 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M11.3121 12.3511L11.0582 7.9983C11.0266 7.45662 11.4574 7 12 7C12.5426 7 12.9734 7.45662 12.9418 7.9983L12.6879 12.3511C12.6666 12.7154 12.365 13 12 13C11.635 13 11.3334 12.7154 11.3121 12.3511Z" fill="currentColor"/><circle cx="11.9999" cy="15.8998" r="1.1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 445 B |
@@ -9,3 +9,8 @@ gpui.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
rust-embed.workspace = true
|
rust-embed.workspace = true
|
||||||
gpui-kit.workspace = true
|
gpui-kit.workspace = true
|
||||||
|
|
||||||
|
# On wasm there is no filesystem to read from, so rust-embed's debug-mode
|
||||||
|
# disk loading returns nothing; embed the assets even in debug builds.
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
rust-embed = { workspace = true, features = ["debug-embed"] }
|
||||||
|
|||||||
@@ -20,12 +20,39 @@ impl AssetSource for CombinedAssets {
|
|||||||
if let Ok(Some(data)) = Assets.load(path) {
|
if let Ok(Some(data)) = Assets.load(path) {
|
||||||
return Ok(Some(data));
|
return Ok(Some(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// The kit bundle fetches icons over HTTP on the web; a single
|
||||||
|
// instance keeps its download cache alive across loads.
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static KIT_ASSETS: OnceLock<gpui_kit::assets::Assets> = OnceLock::new();
|
||||||
|
KIT_ASSETS
|
||||||
|
.get_or_init(gpui_kit::assets::Assets::default)
|
||||||
|
.load(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
gpui_kit::assets::Assets.load(path)
|
gpui_kit::assets::Assets.load(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||||
let mut paths = Assets.list(path).unwrap_or_default();
|
let mut paths = Assets.list(path).unwrap_or_default();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static KIT_ASSETS: OnceLock<gpui_kit::assets::Assets> = OnceLock::new();
|
||||||
|
paths.extend(
|
||||||
|
KIT_ASSETS
|
||||||
|
.get_or_init(gpui_kit::assets::Assets::default)
|
||||||
|
.list(path)?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
paths.extend(gpui_kit::assets::Assets.list(path)?);
|
paths.extend(gpui_kit::assets::Assets.list(path)?);
|
||||||
|
|
||||||
Ok(paths)
|
Ok(paths)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
use gpui::AssetSource;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn icons_resolve_from_the_vendored_folder() {
|
|
||||||
// Icons coop's code and the kit components draw, served by the vendored
|
|
||||||
// folder (signed's set, filled with the kit bundle where coop needs more).
|
|
||||||
let vendored = [
|
|
||||||
"icons/search.svg",
|
|
||||||
"icons/close.svg",
|
|
||||||
"icons/panel-left.svg",
|
|
||||||
"icons/window-minimize.svg",
|
|
||||||
"icons/settings-2.svg",
|
|
||||||
"icons/x.svg",
|
|
||||||
"icons/compass.svg",
|
|
||||||
"icons/radio-tower.svg",
|
|
||||||
];
|
|
||||||
for path in vendored {
|
|
||||||
let own = assets::Assets
|
|
||||||
.load(path)
|
|
||||||
.unwrap_or_else(|e| panic!("{path} errored in own embed: {e}"));
|
|
||||||
assert!(own.is_some(), "own embed is missing {path}");
|
|
||||||
|
|
||||||
let combined = assets::CombinedAssets.load(path).unwrap();
|
|
||||||
assert!(combined.is_some(), "CombinedAssets fails to serve {path}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coop's own files still load through the combined source.
|
|
||||||
let own = [
|
|
||||||
"brand/coop.svg",
|
|
||||||
"brand/bg1.jpg",
|
|
||||||
"fonts/Inter/Inter-Medium.ttf",
|
|
||||||
];
|
|
||||||
for path in own {
|
|
||||||
assert!(
|
|
||||||
matches!(assets::CombinedAssets.load(path), Ok(Some(_))),
|
|
||||||
"CombinedAssets fails to serve coop asset {path}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
gpui-updater-pre = "0.2"
|
gpui-updater-pre = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -37,9 +37,8 @@ pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let arch = match (os, arch) {
|
let arch = match (os, arch) {
|
||||||
// cargo-packager names the disk images `aarch64`/`x64`.
|
// cargo-packager names the disk images `aarch64`.
|
||||||
("macos", "aarch64") => "aarch64",
|
("macos", "aarch64") => "aarch64",
|
||||||
("macos", "x86_64") => "x64",
|
|
||||||
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
|
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
|
||||||
("linux", "aarch64") => "aarch64",
|
("linux", "aarch64") => "aarch64",
|
||||||
("linux", "x86_64") => "x86_64",
|
("linux", "x86_64") => "x86_64",
|
||||||
@@ -243,7 +242,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn picks_the_artifact_matching_os_and_architecture() {
|
fn picks_the_artifact_matching_os_and_architecture() {
|
||||||
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
|
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
|
||||||
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
|
assert_eq!(selected("macos", "x86_64"), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
selected("linux", "aarch64"),
|
selected("linux", "aarch64"),
|
||||||
Some("coop-linux-aarch64.tar.gz")
|
Some("coop-linux-aarch64.tar.gz")
|
||||||
@@ -258,6 +257,7 @@ mod tests {
|
|||||||
fn has_no_target_for_windows_or_unknown_platforms() {
|
fn has_no_target_for_windows_or_unknown_platforms() {
|
||||||
assert_eq!(asset_filter_for("windows", "x86_64"), None);
|
assert_eq!(asset_filter_for("windows", "x86_64"), None);
|
||||||
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
|
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
|
||||||
|
assert_eq!(asset_filter_for("macos", "x86_64"), None);
|
||||||
assert_eq!(asset_filter_for("macos", "riscv64"), None);
|
assert_eq!(asset_filter_for("macos", "riscv64"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ use gpui::{
|
|||||||
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||||
WeakEntity,
|
WeakEntity,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::{Duration, Instant};
|
||||||
use nip4e::{DeviceEvent, DeviceRegistry};
|
use nip4e::{DeviceEvent, DeviceRegistry};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
|
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP, UniversalSigner};
|
||||||
|
|
||||||
mod message;
|
mod message;
|
||||||
mod room;
|
mod room;
|
||||||
@@ -29,7 +29,10 @@ pub use state::FileAttachment;
|
|||||||
|
|
||||||
use crate::search::MessageSearchIndex;
|
use crate::search::MessageSearchIndex;
|
||||||
|
|
||||||
/// A static keypair used only for signing locally-cached rumor events.
|
/// Upper bound on signals applied to the UI in a single update
|
||||||
|
const MAX_SIGNAL_BATCH: usize = 256;
|
||||||
|
|
||||||
|
/// Keypair used only for identifying locally-cached rumor events.
|
||||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
@@ -44,6 +47,7 @@ impl Global for GlobalChatRegistry {}
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum ChatEvent {
|
pub enum ChatEvent {
|
||||||
Ping,
|
Ping,
|
||||||
|
Loaded,
|
||||||
InboxRelayNotFound,
|
InboxRelayNotFound,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
@@ -74,6 +78,13 @@ impl Signal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which signer to attempt when unwrapping a gift wrap
|
||||||
|
enum UnwrapRoute {
|
||||||
|
User,
|
||||||
|
Device,
|
||||||
|
Both,
|
||||||
|
}
|
||||||
|
|
||||||
/// Chat Registry
|
/// Chat Registry
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ChatRegistry {
|
pub struct ChatRegistry {
|
||||||
@@ -216,32 +227,34 @@ impl ChatRegistry {
|
|||||||
let tx = self.signal_tx.clone();
|
let tx = self.signal_tx.clone();
|
||||||
let rx = self.signal_rx.clone();
|
let rx = self.signal_rx.clone();
|
||||||
|
|
||||||
|
let executor = cx.background_executor().clone();
|
||||||
|
|
||||||
self.notification_listener = Some(cx.background_spawn(async move {
|
self.notification_listener = Some(cx.background_spawn(async move {
|
||||||
let mut notifications = client.notifications();
|
|
||||||
let mut processed_events = HashSet::new();
|
|
||||||
const MAX_PROCESSED: usize = 10_000;
|
const MAX_PROCESSED: usize = 10_000;
|
||||||
|
|
||||||
|
let mut notifications = client.notifications();
|
||||||
|
let mut processed_events = HashSet::new();
|
||||||
|
|
||||||
|
let mut user_key = signer.get_public_key_async().await.ok();
|
||||||
|
let device_key = match device_signer.as_ref() {
|
||||||
|
Some(device) => device.get_public_key_async().await.ok(),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut last_yield = Instant::now();
|
||||||
|
|
||||||
while let Some(notification) = notifications.next().await {
|
while let Some(notification) = notifications.next().await {
|
||||||
|
if last_yield.elapsed() >= Duration::from_millis(8) {
|
||||||
|
executor.timer(Duration::from_millis(0)).await;
|
||||||
|
last_yield = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
let ClientNotification::Message { message, relay_url } = notification else {
|
let ClientNotification::Message { message, relay_url } = notification else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
match *message {
|
match *message {
|
||||||
RelayMessage::Event {
|
RelayMessage::Event { event, .. } => {
|
||||||
subscription_id,
|
|
||||||
event,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let chat_sub = subscription_id.as_str() != sub_id1.as_str();
|
|
||||||
let device_sub = subscription_id.as_str() != sub_id2.as_str();
|
|
||||||
|
|
||||||
// Concord wraps are also kind 1059.
|
|
||||||
//
|
|
||||||
// Only the two gift wrap subscriptions carry NIP-59 wraps for this account.
|
|
||||||
if event.kind == Kind::GiftWrap && chat_sub && device_sub {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prune the dedup set before it grows unbounded
|
// Prune the dedup set before it grows unbounded
|
||||||
if processed_events.len() >= MAX_PROCESSED {
|
if processed_events.len() >= MAX_PROCESSED {
|
||||||
processed_events.clear();
|
processed_events.clear();
|
||||||
@@ -253,8 +266,10 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
// Handle msg relays event to determine when the app is ready to subscribe
|
// Handle msg relays event to determine when the app is ready to subscribe
|
||||||
if event.kind == Kind::InboxRelays {
|
if event.kind == Kind::InboxRelays {
|
||||||
let current_user = signer.get_public_key_async().await?;
|
if user_key.is_none() {
|
||||||
if event.pubkey == current_user {
|
user_key = signer.get_public_key_async().await.ok();
|
||||||
|
}
|
||||||
|
if Some(event.pubkey) == user_key {
|
||||||
tx.send_async(Signal::InboxReady).await?;
|
tx.send_async(Signal::InboxReady).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,15 +285,39 @@ impl ChatRegistry {
|
|||||||
seen.entry(event.id).or_default().insert(relay_url);
|
seen.entry(event.id).or_default().insert(relay_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Route to the signer matching the recipient tag
|
||||||
|
// so a wrap is never decrypted twice.
|
||||||
|
let recipient = event.tags.public_keys().next();
|
||||||
|
let route = match recipient {
|
||||||
|
Some(key) if Some(key) == device_key => UnwrapRoute::Device,
|
||||||
|
Some(key) if Some(key) == user_key => UnwrapRoute::User,
|
||||||
|
Some(_) if user_key.is_some() && device_key.is_some() => continue,
|
||||||
|
_ => UnwrapRoute::Both,
|
||||||
|
};
|
||||||
|
|
||||||
// Extract the rumor from the gift wrap event
|
// Extract the rumor from the gift wrap event
|
||||||
match extract_rumor(
|
let result = match route {
|
||||||
|
UnwrapRoute::User => {
|
||||||
|
extract_rumor(&client, &signer, None, event.as_ref()).await
|
||||||
|
}
|
||||||
|
UnwrapRoute::Device => match device_signer.as_ref() {
|
||||||
|
Some(device) => {
|
||||||
|
extract_rumor(&client, device, None, event.as_ref()).await
|
||||||
|
}
|
||||||
|
None => continue,
|
||||||
|
},
|
||||||
|
UnwrapRoute::Both => {
|
||||||
|
extract_rumor(
|
||||||
&client,
|
&client,
|
||||||
&signer,
|
&signer,
|
||||||
device_signer.as_ref(),
|
device_signer.as_ref(),
|
||||||
event.as_ref(),
|
event.as_ref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
Ok(rumor) => {
|
Ok(rumor) => {
|
||||||
let Some(rumor_id) = rumor.id else {
|
let Some(rumor_id) = rumor.id else {
|
||||||
log::error!("Rumor missing id after ensure_id");
|
log::error!("Rumor missing id after ensure_id");
|
||||||
@@ -317,35 +356,57 @@ impl ChatRegistry {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let tracking = self.tracking.clone();
|
||||||
|
|
||||||
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
||||||
while let Ok(message) = rx.recv_async().await {
|
while let Ok(first) = rx.recv_async().await {
|
||||||
match message {
|
// Batch whatever has already arrived so the UI updates once per burst
|
||||||
Signal::Message(message) => {
|
let mut messages = Vec::new();
|
||||||
this.update(cx, |this, cx| {
|
let mut errors = Vec::new();
|
||||||
this.new_message(message, cx);
|
let mut inbox_ready = false;
|
||||||
})?;
|
let mut eose = false;
|
||||||
|
|
||||||
|
let mut pending = Some(first);
|
||||||
|
while let Some(signal) = pending.take() {
|
||||||
|
match signal {
|
||||||
|
Signal::Message(message) => messages.push(message),
|
||||||
|
Signal::InboxReady => inbox_ready = true,
|
||||||
|
Signal::Eose => eose = true,
|
||||||
|
Signal::Error(failed) => errors.push(failed),
|
||||||
}
|
}
|
||||||
Signal::InboxReady => {
|
|
||||||
|
if messages.len() + errors.len() >= MAX_SIGNAL_BATCH {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending = rx.try_recv().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
if inbox_ready {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.get_messages(cx);
|
this.get_messages(cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Signal::Eose => {
|
|
||||||
this.update(cx, |this, _cx| {
|
|
||||||
this.tracking.store(false, Ordering::Release);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
|
if !messages.is_empty() {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.get_rooms(cx);
|
this.new_messages(messages, cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Signal::Error(failed) => {
|
|
||||||
trash.update(cx, |this, cx| {
|
if !errors.is_empty() {
|
||||||
this.insert(failed);
|
trash.update(cx, |trash, cx| {
|
||||||
|
trash.extend(errors);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
if eose && tracking.swap(false, Ordering::AcqRel) {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
cx.emit(ChatEvent::Loaded);
|
||||||
|
this.get_rooms(cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -669,6 +730,7 @@ impl ChatRegistry {
|
|||||||
self.rooms.clear();
|
self.rooms.clear();
|
||||||
self.room_index.clear();
|
self.room_index.clear();
|
||||||
self.search.write().unwrap().clear();
|
self.search.write().unwrap().clear();
|
||||||
|
self.tracking.store(true, Ordering::Release);
|
||||||
self.trash.update(cx, |this, cx| {
|
self.trash.update(cx, |this, cx| {
|
||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -748,6 +810,7 @@ impl ChatRegistry {
|
|||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let search = self.search.clone();
|
let search = self.search.clone();
|
||||||
|
let executor = cx.background_executor().clone();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let public_key = signer.get_public_key_async().await?;
|
let public_key = signer.get_public_key_async().await?;
|
||||||
@@ -775,7 +838,15 @@ impl ChatRegistry {
|
|||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||||
|
|
||||||
|
// Keep the single-threaded web event loop responsive during large rescans
|
||||||
|
let mut last_yield = Instant::now();
|
||||||
|
|
||||||
for raw in events.into_iter() {
|
for raw in events.into_iter() {
|
||||||
|
if last_yield.elapsed() >= Duration::from_millis(8) {
|
||||||
|
executor.timer(Duration::from_millis(0)).await;
|
||||||
|
last_yield = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
||||||
&& rumor.tags.public_keys().next().is_some()
|
&& rumor.tags.public_keys().next().is_some()
|
||||||
{
|
{
|
||||||
@@ -811,12 +882,17 @@ impl ChatRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a nostr event into a message and push it to the belonging room
|
/// Parse nostr events into messages and push them to the belonging rooms
|
||||||
///
|
///
|
||||||
/// - If the room doesn't exist, it will be created.
|
/// - If a room doesn't exist, it will be created.
|
||||||
/// - Updates room ordering based on the most recent messages.
|
/// - Room ordering is updated once after the whole batch.
|
||||||
pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
pub(crate) fn new_messages(&mut self, messages: Vec<NewMessage>, cx: &mut Context<Self>) {
|
||||||
self.search.write().unwrap().insert(&message.rumor);
|
{
|
||||||
|
let mut search = self.search.write().unwrap();
|
||||||
|
for message in &messages {
|
||||||
|
search.insert(&message.rumor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
@@ -824,17 +900,18 @@ impl ChatRegistry {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
for message in messages {
|
||||||
match self.room_index.get(&message.room).cloned() {
|
match self.room_index.get(&message.room).cloned() {
|
||||||
Some(room) => {
|
Some(room) => {
|
||||||
room.update(cx, |this, cx| {
|
room.update(cx, |this, cx| {
|
||||||
if this.kind == RoomKind::Request
|
if this.kind == RoomKind::Request
|
||||||
&& (message.rumor.pubkey == public_key || this.auto_trusted(public_key, cx))
|
&& (message.rumor.pubkey == public_key
|
||||||
|
|| this.auto_trusted(public_key, cx))
|
||||||
{
|
{
|
||||||
this.set_ongoing(cx);
|
this.set_ongoing(cx);
|
||||||
}
|
}
|
||||||
this.push_message(message, cx);
|
this.push_message(message, cx);
|
||||||
});
|
});
|
||||||
self.sort(cx);
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Push the new room to the front of the list
|
// Push the new room to the front of the list
|
||||||
@@ -842,19 +919,23 @@ impl ChatRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.sort(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a nostr event into a message and push it to the belonging room
|
||||||
|
pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||||
|
self.new_messages(vec![message], cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unwraps a gift-wrapped event and processes its contents.
|
/// Unwraps a gift-wrapped event and processes its contents.
|
||||||
async fn extract_rumor<S, D>(
|
async fn extract_rumor(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
signer: &S,
|
signer: &UniversalSigner,
|
||||||
device_signer: Option<&D>,
|
device_signer: Option<&UniversalSigner>,
|
||||||
gift_wrap: &Event,
|
gift_wrap: &Event,
|
||||||
) -> Result<UnsignedEvent, Error>
|
) -> Result<UnsignedEvent, Error> {
|
||||||
where
|
|
||||||
S: AsyncNip44 + ?Sized,
|
|
||||||
D: AsyncNip44 + ?Sized,
|
|
||||||
{
|
|
||||||
// Try to get cached rumor first
|
// Try to get cached rumor first
|
||||||
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
|
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
|
||||||
return Ok(rumor);
|
return Ok(rumor);
|
||||||
@@ -888,10 +969,10 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to unwrap a gift wrap event with a given signer.
|
/// Attempts to unwrap a gift wrap event with a given signer.
|
||||||
async fn try_unwrap_with<S>(signer: &S, gift_wrap: &Event) -> Result<UnwrappedGift, Error>
|
async fn try_unwrap_with(
|
||||||
where
|
signer: &UniversalSigner,
|
||||||
S: AsyncNip44 + ?Sized,
|
gift_wrap: &Event,
|
||||||
{
|
) -> Result<UnwrappedGift, Error> {
|
||||||
// Get the sealed event
|
// Get the sealed event
|
||||||
let seal = signer
|
let seal = signer
|
||||||
.nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content)
|
.nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content)
|
||||||
@@ -926,17 +1007,27 @@ where
|
|||||||
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
||||||
let room_id = rumor.uniq_id().to_string();
|
let room_id = rumor.uniq_id().to_string();
|
||||||
|
|
||||||
let tags = vec![
|
let tags = Tags::from_list(vec![
|
||||||
Tag::identifier(id),
|
Tag::identifier(id),
|
||||||
Tag::public_key(rumor.pubkey),
|
Tag::public_key(rumor.pubkey),
|
||||||
Tag::custom("r", [room_id]),
|
Tag::custom("r", [room_id]),
|
||||||
Tag::custom("k", [rumor.kind.to_string()]),
|
Tag::custom("k", [rumor.kind.to_string()]),
|
||||||
];
|
]);
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
let public_key = LOCAL_KEYS.public_key();
|
||||||
.tags(tags)
|
let created_at = Timestamp::now();
|
||||||
.finalize_async(&*LOCAL_KEYS)
|
let kind = Kind::ApplicationSpecificData;
|
||||||
.await?;
|
let content = rumor.as_json();
|
||||||
|
|
||||||
|
let event = Event::new(
|
||||||
|
EventId::compute(&public_key, &created_at, &kind, &tags, &content),
|
||||||
|
public_key,
|
||||||
|
created_at,
|
||||||
|
kind,
|
||||||
|
tags,
|
||||||
|
content,
|
||||||
|
Signature::from_byte_array([0u8; 64]),
|
||||||
|
);
|
||||||
|
|
||||||
client.database().save_event(&event).await?;
|
client.database().save_event(&event).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
use std::hash::Hash;
|
use std::hash::Hash;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls};
|
use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls, nevent_refs};
|
||||||
use gpui::{SharedString, SharedUri};
|
use gpui::{SharedString, SharedUri};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::FileAttachment;
|
use state::FileAttachment;
|
||||||
|
|
||||||
pub(crate) const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
pub(crate) const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||||
|
|
||||||
const SCHEME_WITH_COLON: &str = "nostr:";
|
|
||||||
const NEVENT_PREFIX: &str = "nevent1";
|
|
||||||
|
|
||||||
/// Rendered message.
|
/// Rendered message.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
@@ -248,61 +245,6 @@ fn extract_mentions(content: &str) -> Vec<Mention> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `nevent` reference found in a content string.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct NeventRef {
|
|
||||||
/// Range of the reference, including the optional `nostr:` scheme
|
|
||||||
pub range: Range<usize>,
|
|
||||||
/// Compact display label, like `https://njump.me/nevent1...d9sp`
|
|
||||||
pub label: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
|
|
||||||
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
|
|
||||||
let mut references = Vec::new();
|
|
||||||
|
|
||||||
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
|
|
||||||
// Only lowercase references are matched, matching NIP-19 encodings in practice
|
|
||||||
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
|
|
||||||
match_start - SCHEME_WITH_COLON.len()
|
|
||||||
} else {
|
|
||||||
match_start
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
|
|
||||||
if content[..start].ends_with(char::is_alphanumeric) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let end = match_start
|
|
||||||
+ content[match_start..]
|
|
||||||
.bytes()
|
|
||||||
.take_while(u8::is_ascii_alphanumeric)
|
|
||||||
.count();
|
|
||||||
|
|
||||||
// The bech32 token excludes the optional `nostr:` scheme
|
|
||||||
let bech32 = &content[match_start..end];
|
|
||||||
if Nip19Event::from_bech32(bech32).is_err() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
references.push(NeventRef {
|
|
||||||
range: start..end,
|
|
||||||
label: nevent_label(bech32),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
references
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
|
|
||||||
fn nevent_label(bech32: &str) -> String {
|
|
||||||
match bech32.len() {
|
|
||||||
0..=4 => format!("https://njump.me/{bech32}"),
|
|
||||||
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts all reply (ids) from the event tags.
|
/// Extracts all reply (ids) from the event tags.
|
||||||
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
||||||
let mut replies_to = vec![];
|
let mut replies_to = vec![];
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
|
speech = { path = "../speech" }
|
||||||
ui = { path = "../ui" }
|
ui = { path = "../ui" }
|
||||||
gpui-kit.workspace = true
|
gpui-kit = { workspace = true, features = ["speech"] }
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
chat = { path = "../chat" }
|
chat = { path = "../chat" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
instant.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use gpui::Action;
|
use gpui::{Action, actions};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use settings::SignerKind;
|
use settings::SignerKind;
|
||||||
@@ -15,3 +15,6 @@ pub enum Command {
|
|||||||
Njump(PublicKey),
|
Njump(PublicKey),
|
||||||
Trace(EventId),
|
Trace(EventId),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start or stop dictation in the focused composer
|
||||||
|
actions!(chat, [ToggleDictation]);
|
||||||
|
|||||||
@@ -1,32 +1,70 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use anyhow::Error;
|
||||||
|
|
||||||
use chat::FileAttachment;
|
|
||||||
use gpui::SharedString;
|
use gpui::SharedString;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use state::{FileAttachment, LocalFile, PickedFile};
|
||||||
|
|
||||||
/// A file attachment that has been uploaded, but not sent yet.
|
/// A file attachment that has been uploaded, but not sent yet.
|
||||||
///
|
///
|
||||||
/// The local `path` is kept around so the composer can preview
|
/// The `local` handle is kept around so the composer can preview
|
||||||
/// the file without downloading and decrypting it again.
|
/// the file without downloading and decrypting it again.
|
||||||
pub(crate) struct PendingFile {
|
pub(crate) struct PendingFile {
|
||||||
pub file: FileAttachment,
|
pub file: FileAttachment,
|
||||||
pub path: PathBuf,
|
pub local: LocalFile,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State of the encrypted file attachment of a message
|
/// State of the encrypted file attachment of a message
|
||||||
pub(crate) enum DecryptedFile {
|
pub(crate) enum DecryptedFile {
|
||||||
Loading,
|
Loading,
|
||||||
Ready(PathBuf),
|
Ready(LocalFile),
|
||||||
Failed(SharedString),
|
Failed(SharedString),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of an upload, either plain or encrypted
|
/// Result of an upload, either plain or encrypted
|
||||||
pub(crate) enum Uploaded {
|
pub(crate) enum Uploaded {
|
||||||
Url(Url),
|
Url(Url),
|
||||||
File(FileAttachment, PathBuf),
|
File(Box<UploadedFile>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `file://` url for a decrypted file, so it can be opened by the OS
|
/// An encrypted upload and the handle to preview it locally
|
||||||
pub(crate) fn file_url(path: &Path) -> String {
|
pub(crate) struct UploadedFile {
|
||||||
format!("file://{}", path.display())
|
pub file: FileAttachment,
|
||||||
|
pub local: LocalFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Uploaded {
|
||||||
|
/// Build the upload result for a picked file, encrypted when the
|
||||||
|
/// attachment is the whole message
|
||||||
|
pub(crate) async fn from_pick(
|
||||||
|
encrypted: bool,
|
||||||
|
server: Url,
|
||||||
|
picked: PickedFile,
|
||||||
|
cx: &gpui::AsyncApp,
|
||||||
|
) -> Result<Self, Error> {
|
||||||
|
if encrypted {
|
||||||
|
let UploadedFileParts { file, local } = upload_encrypted(server, picked, cx).await?;
|
||||||
|
|
||||||
|
Ok(Self::File(Box::new(UploadedFile { file, local })))
|
||||||
|
} else {
|
||||||
|
state::upload_file(server, picked, cx).await.map(Self::Url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pieces of an encrypted upload, split so the local handle survives the
|
||||||
|
/// picked file being consumed by the upload
|
||||||
|
struct UploadedFileParts {
|
||||||
|
file: FileAttachment,
|
||||||
|
local: LocalFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_encrypted(
|
||||||
|
server: Url,
|
||||||
|
picked: PickedFile,
|
||||||
|
cx: &gpui::AsyncApp,
|
||||||
|
) -> Result<UploadedFileParts, Error> {
|
||||||
|
let local = picked.local.clone();
|
||||||
|
|
||||||
|
state::upload_encrypted_file(server, picked, cx)
|
||||||
|
.await
|
||||||
|
.map(|file| UploadedFileParts { file, local })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::{Arc, LazyLock, RwLock};
|
use std::sync::{Arc, LazyLock, RwLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
@@ -10,18 +10,18 @@ use gpui::prelude::FluentBuilder;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
ObjectFit, ParentElement, Render, SharedString, SharedUri, StatefulInteractiveElement, Styled,
|
||||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, WeakEntity,
|
||||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
Window, div, img, list, px, red, relative, retain_all, svg, white,
|
||||||
retain_all, svg, white,
|
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||||
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||||
use gpui_kit::component::menu::DropdownMenu;
|
use gpui_kit::component::menu::DropdownMenu;
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
use gpui_kit::component::scroll::Scrollbar;
|
use gpui_kit::component::scroll::Scrollbar;
|
||||||
|
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
|
||||||
use gpui_kit::component::tooltip::Tooltip;
|
use gpui_kit::component::tooltip::Tooltip;
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
@@ -31,18 +31,15 @@ use regex::Regex;
|
|||||||
use settings::{AppSettings, SignerKind};
|
use settings::{AppSettings, SignerKind};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{
|
use state::{
|
||||||
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
|
FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file,
|
||||||
|
};
|
||||||
|
use ui::{
|
||||||
|
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow,
|
||||||
|
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, WelcomeMessage, group_reactions,
|
||||||
|
opens_run, rendered_text,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::file::*;
|
use crate::file::*;
|
||||||
use crate::message::{
|
|
||||||
Avatar, MessageMedia, MessageRow, ReactionBar, ReplyPreview, WelcomeMessage, group_reactions,
|
|
||||||
opens_run,
|
|
||||||
};
|
|
||||||
use crate::text::RenderedText;
|
|
||||||
|
|
||||||
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
|
||||||
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
|
|
||||||
|
|
||||||
/// Notice shown when a conversation has no messages, and above the message list.
|
/// Notice shown when a conversation has no messages, and above the message list.
|
||||||
const PRIVATE_NOTICE: &str =
|
const PRIVATE_NOTICE: &str =
|
||||||
@@ -59,8 +56,6 @@ static CUSTOM_EMOJI_RE: LazyLock<Regex> =
|
|||||||
|
|
||||||
mod actions;
|
mod actions;
|
||||||
mod file;
|
mod file;
|
||||||
pub mod message;
|
|
||||||
pub mod text;
|
|
||||||
|
|
||||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||||
cx.new(|cx| ChatPanel::new(room, window, cx))
|
cx.new(|cx| ChatPanel::new(room, window, cx))
|
||||||
@@ -98,6 +93,12 @@ pub struct ChatPanel {
|
|||||||
/// Chat input state
|
/// Chat input state
|
||||||
input: Entity<TextareaState>,
|
input: Entity<TextareaState>,
|
||||||
|
|
||||||
|
/// Dictation session for the chat input
|
||||||
|
speech: Entity<SpeechState>,
|
||||||
|
|
||||||
|
/// When the running dictation started, for the elapsed counter
|
||||||
|
speech_started: Option<instant::Instant>,
|
||||||
|
|
||||||
/// Subject input state
|
/// Subject input state
|
||||||
subject_input: Entity<InputState>,
|
subject_input: Entity<InputState>,
|
||||||
|
|
||||||
@@ -123,7 +124,7 @@ pub struct ChatPanel {
|
|||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
subscriptions: SmallVec<[Subscription; 3]>,
|
subscriptions: SmallVec<[Subscription; 4]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatPanel {
|
impl ChatPanel {
|
||||||
@@ -167,6 +168,19 @@ impl ChatPanel {
|
|||||||
.clean_on_escape()
|
.clean_on_escape()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Define dictation state
|
||||||
|
let speech = cx.new(|cx| {
|
||||||
|
let state = SpeechState::new(cx)
|
||||||
|
.recognizer(speech::recognizer())
|
||||||
|
// Batch transcription of a long recording can take a while.
|
||||||
|
.stop_timeout(Duration::from_secs(180));
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let state = state.input(speech::input());
|
||||||
|
|
||||||
|
state
|
||||||
|
});
|
||||||
|
|
||||||
// Define subject input state
|
// Define subject input state
|
||||||
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
|
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
|
||||||
let subject_bar = cx.new(|_cx| false);
|
let subject_bar = cx.new(|_cx| false);
|
||||||
@@ -185,15 +199,39 @@ impl ChatPanel {
|
|||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe the subject input event
|
// Subscribe the subject input event
|
||||||
cx.subscribe_in(
|
cx.subscribe_in(&subject_input, window, move |this, _, event, window, cx| {
|
||||||
&subject_input,
|
|
||||||
window,
|
|
||||||
move |this, _input, event, window, cx| {
|
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
this.change_subject(window, cx);
|
this.change_subject(window, cx);
|
||||||
};
|
};
|
||||||
},
|
}),
|
||||||
),
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Insert the dictated text into the chat input
|
||||||
|
cx.subscribe_in(&speech, window, move |this, _speech, event, window, cx| {
|
||||||
|
match event {
|
||||||
|
SpeechEvent::Started => {
|
||||||
|
this.speech_started = Some(instant::Instant::now());
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
SpeechEvent::Final(text) => {
|
||||||
|
this.speech_started = None;
|
||||||
|
|
||||||
|
if !text.is_empty() {
|
||||||
|
this.input
|
||||||
|
.update(cx, |input, cx| input.insert(text.clone(), window, cx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SpeechEvent::Error(error) => {
|
||||||
|
this.speech_started = None;
|
||||||
|
window.push_notification(Notification::error(error.to_string()), cx);
|
||||||
|
}
|
||||||
|
SpeechEvent::Cancelled => {
|
||||||
|
this.speech_started = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Define all functions that will run after the current cycle
|
// Define all functions that will run after the current cycle
|
||||||
@@ -213,6 +251,8 @@ impl ChatPanel {
|
|||||||
directory,
|
directory,
|
||||||
list_state,
|
list_state,
|
||||||
input,
|
input,
|
||||||
|
speech,
|
||||||
|
speech_started: None,
|
||||||
subject_input,
|
subject_input,
|
||||||
subject_bar,
|
subject_bar,
|
||||||
replies_to,
|
replies_to,
|
||||||
@@ -647,22 +687,17 @@ impl ChatPanel {
|
|||||||
// Encrypt attachments which are not part of a message being written
|
// Encrypt attachments which are not part of a message being written
|
||||||
let encrypted = self.input.read(cx).value().trim().is_empty();
|
let encrypted = self.input.read(cx).value().trim().is_empty();
|
||||||
|
|
||||||
// Ask user for file upload
|
// Ask the user for a file to upload
|
||||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
let picker = pick_file(cx);
|
||||||
files: true,
|
|
||||||
directories: false,
|
|
||||||
multiple: false,
|
|
||||||
prompt: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
// Selecting no file means the prompt was cancelled
|
// Selecting no file means the prompt was cancelled
|
||||||
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
let Some(picked) = picker.await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.upload_file(server, path, encrypted, window, cx);
|
this.upload_file(server, picked, encrypted, window, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -673,7 +708,7 @@ impl ChatPanel {
|
|||||||
fn upload_file(
|
fn upload_file(
|
||||||
&mut self,
|
&mut self,
|
||||||
server: Url,
|
server: Url,
|
||||||
path: PathBuf,
|
picked: PickedFile,
|
||||||
encrypted: bool,
|
encrypted: bool,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
@@ -681,25 +716,18 @@ impl ChatPanel {
|
|||||||
self.set_uploading(true, cx);
|
self.set_uploading(true, cx);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
let result = if encrypted {
|
let result = Uploaded::from_pick(encrypted, server.clone(), picked, cx).await;
|
||||||
upload_encrypted(server.clone(), path.clone(), cx)
|
|
||||||
.await
|
|
||||||
.map(|file| Uploaded::File(file, path.clone()))
|
|
||||||
} else {
|
|
||||||
upload(server.clone(), path.clone(), cx)
|
|
||||||
.await
|
|
||||||
.map(Uploaded::Url)
|
|
||||||
};
|
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.set_uploading(false, cx);
|
this.set_uploading(false, cx);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
|
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
|
||||||
Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx),
|
Ok(Uploaded::File(uploaded)) => {
|
||||||
Err(e) if encrypted => {
|
let UploadedFile { file, local } = *uploaded;
|
||||||
this.report_encrypted_upload_error(server, path, e, window, cx)
|
this.add_pending_file(file, local, cx);
|
||||||
}
|
}
|
||||||
|
Err(e) if encrypted => this.report_encrypted_upload_error(e, window, cx),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
window.push_notification(
|
window.push_notification(
|
||||||
Notification::error(e.to_string()).autohide(false),
|
Notification::error(e.to_string()).autohide(false),
|
||||||
@@ -716,8 +744,6 @@ impl ChatPanel {
|
|||||||
/// Report a failed encrypted upload, offering to retry it without encryption
|
/// Report a failed encrypted upload, offering to retry it without encryption
|
||||||
fn report_encrypted_upload_error(
|
fn report_encrypted_upload_error(
|
||||||
&mut self,
|
&mut self,
|
||||||
server: Url,
|
|
||||||
path: PathBuf,
|
|
||||||
error: Error,
|
error: Error,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
@@ -729,14 +755,12 @@ impl ChatPanel {
|
|||||||
.title("Encrypted upload failed")
|
.title("Encrypted upload failed")
|
||||||
.action(move |_this, _window, _cx| {
|
.action(move |_this, _window, _cx| {
|
||||||
let view = view.clone();
|
let view = view.clone();
|
||||||
let server = server.clone();
|
|
||||||
let path = path.clone();
|
|
||||||
|
|
||||||
Button::new("retry-without-encryption")
|
Button::new("retry-without-encryption")
|
||||||
.label("Upload without encryption")
|
.label("Pick the file again without encryption")
|
||||||
.on_click(move |_ev, window, cx| {
|
.on_click(move |_ev, window, cx| {
|
||||||
view.update(cx, |this, cx| {
|
view.update(cx, |this, cx| {
|
||||||
this.upload_file(server.clone(), path.clone(), false, window, cx);
|
this.upload(window, cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
})
|
})
|
||||||
@@ -766,9 +790,9 @@ impl ChatPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context<Self>) {
|
fn add_pending_file(&mut self, file: FileAttachment, local: LocalFile, cx: &mut Context<Self>) {
|
||||||
self.encrypted_attachments.update(cx, |this, cx| {
|
self.encrypted_attachments.update(cx, |this, cx| {
|
||||||
this.push(PendingFile { file, path });
|
this.push(PendingFile { file, local });
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -787,12 +811,13 @@ impl ChatPanel {
|
|||||||
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let result = download_and_decrypt_to_file(&file, cx).await;
|
let result = download_and_decrypt_file(&file, cx).await;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
match result {
|
match result {
|
||||||
Ok(path) => {
|
Ok(downloaded) => {
|
||||||
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
this.decrypted_files
|
||||||
|
.insert(id, DecryptedFile::Ready(downloaded.local));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.decrypted_files
|
this.decrypted_files
|
||||||
@@ -807,7 +832,7 @@ impl ChatPanel {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt the attachment of a file message and open it with the OS
|
/// Decrypt the attachment of a file message and open it with the platform handler
|
||||||
fn open_file(
|
fn open_file(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: EventId,
|
id: EventId,
|
||||||
@@ -815,25 +840,30 @@ impl ChatPanel {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
match self.decrypted_files.get(&id) {
|
if let Some(DecryptedFile::Ready(local)) = self.decrypted_files.get(&id) {
|
||||||
Some(DecryptedFile::Ready(path)) => {
|
let _ = local.open(cx).map_err(|e| {
|
||||||
cx.open_url(&file_url(path));
|
window.push_notification(Notification::error(e.to_string()).autohide(false), cx);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(DecryptedFile::Loading) = self.decrypted_files.get(&id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Some(DecryptedFile::Loading) => return,
|
|
||||||
_ => {}
|
|
||||||
};
|
|
||||||
|
|
||||||
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
let result = download_and_decrypt_to_file(&file, cx).await;
|
let result = download_and_decrypt_file(&file, cx).await;
|
||||||
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
match result {
|
match result {
|
||||||
Ok(path) => {
|
Ok(downloaded) => {
|
||||||
cx.open_url(&file_url(&path));
|
if let Err(e) = downloaded.open(cx) {
|
||||||
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
|
}
|
||||||
|
this.decrypted_files
|
||||||
|
.insert(id, DecryptedFile::Ready(downloaded.local));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.decrypted_files
|
this.decrypted_files
|
||||||
@@ -852,6 +882,17 @@ impl ChatPanel {
|
|||||||
self.directory.read(cx).person(public_key, cx)
|
self.directory.read(cx).person(public_key, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn toggle_dictation(
|
||||||
|
&mut self,
|
||||||
|
_: &ToggleDictation,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.speech.update(cx, |speech, cx| {
|
||||||
|
speech.toggle(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
match command {
|
match command {
|
||||||
Command::Insert(content) => {
|
Command::Insert(content) => {
|
||||||
@@ -1049,7 +1090,7 @@ impl ChatPanel {
|
|||||||
.rounded_full()
|
.rounded_full()
|
||||||
.bg(cx.theme().warning.opacity(0.15))
|
.bg(cx.theme().warning.opacity(0.15))
|
||||||
.text_color(cx.theme().warning)
|
.text_color(cx.theme().warning)
|
||||||
.child(Icon::new(IconName::TriangleAlert).small()),
|
.child(Icon::new(IconName::Warning).small()),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -1107,9 +1148,12 @@ impl ChatPanel {
|
|||||||
.rendered_texts_by_id
|
.rendered_texts_by_id
|
||||||
.entry(message.id)
|
.entry(message.id)
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
text::rendered_text(
|
rendered_text(
|
||||||
&message.content,
|
&message.content,
|
||||||
&message.mentions,
|
message
|
||||||
|
.mentions
|
||||||
|
.iter()
|
||||||
|
.map(|mention| (mention.range.clone(), mention.public_key)),
|
||||||
&message.emojis,
|
&message.emojis,
|
||||||
&directory,
|
&directory,
|
||||||
cx,
|
cx,
|
||||||
@@ -1154,6 +1198,7 @@ impl ChatPanel {
|
|||||||
.avatar(
|
.avatar(
|
||||||
Avatar::new(author.avatar())
|
Avatar::new(author.avatar())
|
||||||
.seed(author.avatar_seed())
|
.seed(author.avatar_seed())
|
||||||
|
.small()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.relative()
|
.relative()
|
||||||
.dropdown_menu(move |this, _window, _cx| {
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
@@ -1295,7 +1340,7 @@ impl ChatPanel {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
|
.child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
|
||||||
.child(name.clone()),
|
.child(name.clone()),
|
||||||
),
|
),
|
||||||
@@ -1334,7 +1379,7 @@ impl ChatPanel {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.line_height(relative(1.25))
|
.line_height(relative(1.25))
|
||||||
.child(SharedString::from(url.to_string())),
|
.child(SharedString::from(url.to_string())),
|
||||||
)
|
)
|
||||||
@@ -1364,7 +1409,7 @@ impl ChatPanel {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.line_height(relative(1.25))
|
.line_height(relative(1.25))
|
||||||
.child(SharedString::from(url.0.to_string())),
|
.child(SharedString::from(url.0.to_string())),
|
||||||
)
|
)
|
||||||
@@ -1390,83 +1435,39 @@ impl ChatPanel {
|
|||||||
public_key: &PublicKey,
|
public_key: &PublicKey,
|
||||||
cx: &Context<Self>,
|
cx: &Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
h_flex()
|
let mut actions = MessageActions::new();
|
||||||
.p_0p5()
|
|
||||||
.gap_1()
|
|
||||||
.invisible()
|
|
||||||
.absolute()
|
|
||||||
.right_4()
|
|
||||||
.top_neg_2()
|
|
||||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
|
||||||
.rounded(cx.theme().radius_lg)
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border)
|
|
||||||
.bg(cx.theme().background)
|
|
||||||
.children({
|
|
||||||
let mut items = vec![];
|
|
||||||
|
|
||||||
for emoji in COMPACT_REACTION_EMOJIS {
|
for emoji in COMPACT_REACTION_EMOJIS {
|
||||||
items.push(
|
|
||||||
Button::new(*emoji)
|
|
||||||
.label(*emoji)
|
|
||||||
.tooltip(*emoji)
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.on_click({
|
|
||||||
let emoji = *emoji;
|
|
||||||
let id = *id;
|
let id = *id;
|
||||||
|
actions = actions.reaction(
|
||||||
|
emoji,
|
||||||
cx.listener(move |this, _event, window, cx| {
|
cx.listener(move |this, _event, window, cx| {
|
||||||
this.send_reaction(emoji, &id, window, cx);
|
this.send_reaction(emoji, &id, window, cx);
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
items
|
let reply_id = *id;
|
||||||
})
|
let dropdown_id = *id;
|
||||||
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
|
||||||
.child(
|
|
||||||
Button::new("reply")
|
|
||||||
.icon(IconName::Reply)
|
|
||||||
.tooltip("Reply")
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.on_click({
|
|
||||||
let id = id.to_owned();
|
|
||||||
cx.listener(move |this, _event, _window, cx| {
|
|
||||||
this.reply_to(&id, cx);
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("copy")
|
|
||||||
.icon(IconName::Copy)
|
|
||||||
.tooltip("Copy")
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.on_click({
|
|
||||||
let id = id.to_owned();
|
|
||||||
cx.listener(move |this, _event, _window, cx| {
|
|
||||||
this.copy_message(&id, cx);
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
|
||||||
.child(
|
|
||||||
Button::new("advance")
|
|
||||||
.icon(IconName::Ellipsis)
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.dropdown_menu({
|
|
||||||
let public_key = *public_key;
|
let public_key = *public_key;
|
||||||
let id = *id;
|
|
||||||
move |this, _window, _cx| {
|
let actions = actions.reply(cx.listener(move |this, _event, _window, cx| {
|
||||||
this.menu("Copy author", Box::new(Command::Copy(public_key)))
|
this.reply_to(&reply_id, cx);
|
||||||
.menu("Seen on", Box::new(Command::Trace(id)))
|
}));
|
||||||
}
|
|
||||||
}),
|
let actions = if let Some(message) = self.message(id) {
|
||||||
|
actions.copy(
|
||||||
|
SharedString::from(format!("message-copy-{id}")),
|
||||||
|
message.preview(),
|
||||||
)
|
)
|
||||||
.group_hover("", |this| this.visible())
|
} else {
|
||||||
|
actions
|
||||||
|
};
|
||||||
|
|
||||||
|
actions.dropdown(move |menu, _window, _cx| {
|
||||||
|
menu.menu("Copy author", Box::new(Command::Copy(public_key)))
|
||||||
|
.menu("Seen on", Box::new(Command::Trace(dropdown_id)))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_attachment(&self, url: &Url, cx: &Context<Self>) -> impl IntoElement {
|
fn render_attachment(&self, url: &Url, cx: &Context<Self>) -> impl IntoElement {
|
||||||
@@ -1492,7 +1493,7 @@ impl ChatPanel {
|
|||||||
.justify_center()
|
.justify_center()
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.bg(red())
|
.bg(red())
|
||||||
.child(Icon::new(IconName::X).size_2().text_color(white())),
|
.child(Icon::new(IconName::Close).size_2().text_color(white())),
|
||||||
)
|
)
|
||||||
.on_click({
|
.on_click({
|
||||||
let url = url.clone();
|
let url = url.clone();
|
||||||
@@ -1525,16 +1526,16 @@ impl ChatPanel {
|
|||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let state = self.decrypted_files.get(id);
|
let state = self.decrypted_files.get(id);
|
||||||
|
|
||||||
if let Some(path) = state
|
if let Some(local) = state
|
||||||
.and_then(|state| match state {
|
.and_then(|state| match state {
|
||||||
DecryptedFile::Ready(path) => Some(path),
|
DecryptedFile::Ready(local) => Some(local),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.filter(|_| file.is_image())
|
.filter(|_| file.is_image())
|
||||||
{
|
{
|
||||||
return div()
|
return div()
|
||||||
.child(
|
.child(
|
||||||
img(path.clone())
|
img(local.image_source())
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.h(px(250.))
|
.h(px(250.))
|
||||||
@@ -1610,7 +1611,7 @@ impl ChatPanel {
|
|||||||
.map(|this| {
|
.map(|this| {
|
||||||
if file.is_image() {
|
if file.is_image() {
|
||||||
this.child(
|
this.child(
|
||||||
img(pending.path.clone())
|
img(pending.local.image_source())
|
||||||
.size_16()
|
.size_16()
|
||||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
@@ -1693,7 +1694,7 @@ impl ChatPanel {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("remove-reply")
|
Button::new("remove-reply")
|
||||||
.icon(IconName::X)
|
.icon(IconName::Close)
|
||||||
.xsmall()
|
.xsmall()
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_click({
|
.on_click({
|
||||||
@@ -1731,7 +1732,12 @@ impl ChatPanel {
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_config_menu(&self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
fn render_config_menu(
|
||||||
|
&self,
|
||||||
|
disabled: bool,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
let (backup, signer_kind) = self
|
let (backup, signer_kind) = self
|
||||||
.room
|
.room
|
||||||
.read_with(cx, |this, _cx| {
|
.read_with(cx, |this, _cx| {
|
||||||
@@ -1744,7 +1750,7 @@ impl ChatPanel {
|
|||||||
.icon(IconName::Settings2)
|
.icon(IconName::Settings2)
|
||||||
.tooltip("Configuration")
|
.tooltip("Configuration")
|
||||||
.ghost()
|
.ghost()
|
||||||
.large()
|
.disabled(disabled)
|
||||||
.dropdown_menu(move |this, _window, _cx| {
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
let auto = matches!(signer_kind, SignerKind::Auto);
|
let auto = matches!(signer_kind, SignerKind::Auto);
|
||||||
let encryption = matches!(signer_kind, SignerKind::Encryption);
|
let encryption = matches!(signer_kind, SignerKind::Encryption);
|
||||||
@@ -1775,7 +1781,12 @@ impl ChatPanel {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_emoji_menu(&self, _window: &Window, cx: &Context<Self>) -> impl IntoElement {
|
fn render_emoji_menu(
|
||||||
|
&self,
|
||||||
|
disabled: bool,
|
||||||
|
_window: &Window,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let current_user = nostr.read(cx).current_user();
|
let current_user = nostr.read(cx).current_user();
|
||||||
|
|
||||||
@@ -1790,9 +1801,9 @@ impl ChatPanel {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
Button::new("emoji")
|
Button::new("emoji")
|
||||||
.icon(IconName::Sticker)
|
.icon(IconName::Emoji)
|
||||||
.ghost()
|
.ghost()
|
||||||
.large()
|
.disabled(disabled)
|
||||||
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
||||||
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
|
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
|
||||||
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
|
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
|
||||||
@@ -1838,7 +1849,7 @@ impl Panel for ChatPanel {
|
|||||||
|
|
||||||
Some(vec![
|
Some(vec![
|
||||||
Button::new("subject")
|
Button::new("subject")
|
||||||
.icon(IconName::TextCursorInput)
|
.icon(IconName::Input)
|
||||||
.tooltip("Change subject")
|
.tooltip("Change subject")
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
@@ -1866,10 +1877,12 @@ impl Render for ChatPanel {
|
|||||||
|
|
||||||
let is_typing = !self.input.read(cx).value().trim().is_empty();
|
let is_typing = !self.input.read(cx).value().trim().is_empty();
|
||||||
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
||||||
|
let dictating = self.speech.read(cx).status().is_active();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(retain_all(self.id.clone()))
|
.image_cache(retain_all(self.id.clone()))
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
|
.on_action(cx.listener(Self::toggle_dictation))
|
||||||
.size_full()
|
.size_full()
|
||||||
.when(*self.subject_bar.read(cx), |this| {
|
.when(*self.subject_bar.read(cx), |this| {
|
||||||
this.child(
|
this.child(
|
||||||
@@ -1880,10 +1893,10 @@ impl Render for ChatPanel {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.border_b_1()
|
.border_b_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.child(Input::new(&self.subject_input).small())
|
.child(Input::new(&self.subject_input))
|
||||||
.child(
|
.child(
|
||||||
Button::new("change")
|
Button::new("change")
|
||||||
.icon(IconName::CircleCheck)
|
.icon(IconName::CheckCircle)
|
||||||
.label("Change")
|
.label("Change")
|
||||||
.secondary()
|
.secondary()
|
||||||
.disabled(self.uploading)
|
.disabled(self.uploading)
|
||||||
@@ -1943,29 +1956,56 @@ impl Render for ChatPanel {
|
|||||||
.items_end()
|
.items_end()
|
||||||
.child(
|
.child(
|
||||||
Button::new("upload")
|
Button::new("upload")
|
||||||
.icon(IconName::Plus)
|
.icon(IconName::Paperclip)
|
||||||
.tooltip("Upload media")
|
.tooltip("Upload media")
|
||||||
.loading(self.uploading)
|
.loading(self.uploading)
|
||||||
.disabled(self.uploading)
|
.disabled(dictating || self.uploading)
|
||||||
.ghost()
|
.ghost()
|
||||||
.large()
|
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.upload(window, cx);
|
this.upload(window, cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.child(Textarea::new(&self.input).appearance(false).flex_1())
|
.when(dictating, |this| {
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.h_8()
|
||||||
|
.flex_1()
|
||||||
|
.gap_2()
|
||||||
|
.pl_1()
|
||||||
|
.child(SpeechWaveform::new(&self.speech).small().flex_1())
|
||||||
|
.when_some(self.speech_started, |this, started| {
|
||||||
|
this.child(speech::elapsed(started, cx))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(!dictating, |this| {
|
||||||
|
this.child(
|
||||||
|
Textarea::new(&self.input)
|
||||||
|
.appearance(false)
|
||||||
|
.small()
|
||||||
|
.min_h_8()
|
||||||
|
.py_1()
|
||||||
|
.flex_1(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.items_end()
|
||||||
|
.gap_1()
|
||||||
|
.pl_1()
|
||||||
|
.child(SpeechButton::new(&self.speech)),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.pl_1()
|
.pl_1()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(self.render_emoji_menu(window, cx))
|
.child(self.render_emoji_menu(dictating, window, cx))
|
||||||
.child(self.render_config_menu(window, cx))
|
.child(self.render_config_menu(dictating, window, cx))
|
||||||
.child(
|
.child(
|
||||||
Button::new("send")
|
Button::new("send")
|
||||||
.icon(IconName::SendHorizontal)
|
.icon(IconName::PaperPlaneFill)
|
||||||
.disabled(self.uploading)
|
.disabled(dictating || self.uploading)
|
||||||
.ghost()
|
.ghost()
|
||||||
.large()
|
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.send_text_message(window, cx);
|
this.send_text_message(window, cx);
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -16,3 +16,7 @@ futures.workspace = true
|
|||||||
|
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
|
anyhow.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
web-sys = { version = "0.3", features = ["Window", "Storage"] }
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
pub use debounced_delay::*;
|
|
||||||
pub use display::*;
|
|
||||||
pub use event::*;
|
|
||||||
pub use media_extractor::*;
|
|
||||||
pub use parser::*;
|
|
||||||
pub use paths::*;
|
|
||||||
|
|
||||||
mod debounced_delay;
|
mod debounced_delay;
|
||||||
mod display;
|
mod display;
|
||||||
mod event;
|
mod event;
|
||||||
mod media_extractor;
|
mod media_extractor;
|
||||||
mod parser;
|
mod parser;
|
||||||
mod paths;
|
mod paths;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod web_store;
|
||||||
|
|
||||||
|
pub use debounced_delay::*;
|
||||||
|
pub use display::*;
|
||||||
|
pub use event::*;
|
||||||
|
pub use media_extractor::*;
|
||||||
|
pub use parser::*;
|
||||||
|
pub use paths::*;
|
||||||
|
|||||||
@@ -4,6 +4,62 @@ use nostr::prelude::*;
|
|||||||
|
|
||||||
const BECH32_SEPARATOR: u8 = b'1';
|
const BECH32_SEPARATOR: u8 = b'1';
|
||||||
const SCHEME_WITH_COLON: &str = "nostr:";
|
const SCHEME_WITH_COLON: &str = "nostr:";
|
||||||
|
const NEVENT_PREFIX: &str = "nevent1";
|
||||||
|
|
||||||
|
/// A `nevent` reference found in a content string.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NeventRef {
|
||||||
|
/// Range of the reference, including the optional `nostr:` scheme
|
||||||
|
pub range: Range<usize>,
|
||||||
|
/// Compact display label, like `https://njump.me/nevent1...d9sp`
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
|
||||||
|
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
|
||||||
|
let mut references = Vec::new();
|
||||||
|
|
||||||
|
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
|
||||||
|
// Only lowercase references are matched, matching NIP-19 encodings in practice
|
||||||
|
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
|
||||||
|
match_start - SCHEME_WITH_COLON.len()
|
||||||
|
} else {
|
||||||
|
match_start
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
|
||||||
|
if content[..start].ends_with(char::is_alphanumeric) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let end = match_start
|
||||||
|
+ content[match_start..]
|
||||||
|
.bytes()
|
||||||
|
.take_while(u8::is_ascii_alphanumeric)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
// The bech32 token excludes the optional `nostr:` scheme
|
||||||
|
let bech32 = &content[match_start..end];
|
||||||
|
if Nip19Event::from_bech32(bech32).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
references.push(NeventRef {
|
||||||
|
range: start..end,
|
||||||
|
label: nevent_label(bech32),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
references
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
|
||||||
|
fn nevent_label(bech32: &str) -> String {
|
||||||
|
match bech32.len() {
|
||||||
|
0..=4 => format!("https://njump.me/{bech32}"),
|
||||||
|
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Nostr parsed token with its range in the original text
|
/// Nostr parsed token with its range in the original text
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use anyhow::{Error, anyhow};
|
||||||
|
|
||||||
|
const LOCAL_STORAGE: &str = "localStorage";
|
||||||
|
|
||||||
|
fn storage() -> Result<web_sys::Storage, Error> {
|
||||||
|
web_sys::window()
|
||||||
|
.ok_or_else(|| anyhow!("no browser window"))?
|
||||||
|
.local_storage()
|
||||||
|
.map_err(|e| anyhow!("{LOCAL_STORAGE} unavailable: {e:?}"))?
|
||||||
|
.ok_or_else(|| anyhow!("{LOCAL_STORAGE} unavailable"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a value previously written with [`set`]
|
||||||
|
pub fn get(key: &str) -> Result<Option<String>, Error> {
|
||||||
|
storage()?
|
||||||
|
.get_item(key)
|
||||||
|
.map_err(|e| anyhow!("{LOCAL_STORAGE} read failed: {e:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a value, replacing any previous one
|
||||||
|
pub fn set(key: &str, value: &str) -> Result<(), Error> {
|
||||||
|
storage()?
|
||||||
|
.set_item(key, value)
|
||||||
|
.map_err(|e| anyhow!("{LOCAL_STORAGE} write failed: {e:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a value if it exists
|
||||||
|
pub fn remove(key: &str) -> Result<(), Error> {
|
||||||
|
storage()?
|
||||||
|
.remove_item(key)
|
||||||
|
.map_err(|e| anyhow!("{LOCAL_STORAGE} delete failed: {e:?}"))
|
||||||
|
}
|
||||||
@@ -11,11 +11,13 @@ common = { path = "../common" }
|
|||||||
nip29 = { path = "../nip29" }
|
nip29 = { path = "../nip29" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
speech = { path = "../speech" }
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
gpui-kit.workspace = true
|
gpui-kit = { workspace = true, features = ["speech"] }
|
||||||
ui = { path = "../ui" }
|
ui = { path = "../ui" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
instant.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use gpui::{
|
|||||||
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px,
|
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px,
|
||||||
relative,
|
relative,
|
||||||
};
|
};
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use nip29::{Group, GroupMetadata};
|
use nip29::{Group, GroupMetadata};
|
||||||
use ui::Avatar;
|
use ui::Avatar;
|
||||||
@@ -137,7 +138,7 @@ fn header(group: &Group, cx: &App) -> AnyElement {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.truncate()
|
.truncate()
|
||||||
.child(group.display_name()),
|
.child(group.display_name()),
|
||||||
)
|
)
|
||||||
@@ -162,7 +163,7 @@ fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.font_weight(gpui::FontWeight::MEDIUM)
|
.font_medium()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child(label),
|
.child(label),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chat_ui::message::WelcomeMessage;
|
use chat_ui::ToggleDictation;
|
||||||
use chat_ui::text::{RenderedText, rendered_text};
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
|
FollowMode, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState,
|
||||||
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
|
ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list,
|
||||||
list, px,
|
px,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
||||||
use gpui_kit::component::dialog::DialogButtonProps;
|
use gpui_kit::component::dialog::DialogButtonProps;
|
||||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||||
use gpui_kit::component::input::{InputEvent, Textarea, TextareaState};
|
use gpui_kit::component::input::{InputEvent, Textarea, TextareaState};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
use gpui_kit::component::scroll::Scrollbar;
|
use gpui_kit::component::scroll::Scrollbar;
|
||||||
|
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
|
use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -24,8 +26,8 @@ use person::Directory;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{NostrRegistry, upload};
|
use state::{NostrRegistry, pick_file, upload_file};
|
||||||
use ui::Avatar;
|
use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text};
|
||||||
use util::{attachment_name, display_name, opens_run, pin_label, report};
|
use util::{attachment_name, display_name, opens_run, pin_label, report};
|
||||||
|
|
||||||
mod details;
|
mod details;
|
||||||
@@ -314,6 +316,10 @@ pub struct GroupPanel {
|
|||||||
reply_to: Option<EventId>,
|
reply_to: Option<EventId>,
|
||||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||||
input: Entity<TextareaState>,
|
input: Entity<TextareaState>,
|
||||||
|
/// Dictation session for the composer input
|
||||||
|
speech: Entity<SpeechState>,
|
||||||
|
/// When the running dictation started, for the elapsed counter
|
||||||
|
speech_started: Option<instant::Instant>,
|
||||||
/// Uploaded, non-encrypted attachments waiting to be sent.
|
/// Uploaded, non-encrypted attachments waiting to be sent.
|
||||||
attachments: Vec<Url>,
|
attachments: Vec<Url>,
|
||||||
/// Whether an upload is in flight.
|
/// Whether an upload is in flight.
|
||||||
@@ -321,7 +327,7 @@ pub struct GroupPanel {
|
|||||||
/// Whether the user closed the encryption notice this visit
|
/// Whether the user closed the encryption notice this visit
|
||||||
notice_dismissed: bool,
|
notice_dismissed: bool,
|
||||||
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
||||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GroupPanel {
|
impl GroupPanel {
|
||||||
@@ -340,6 +346,18 @@ impl GroupPanel {
|
|||||||
.clean_on_escape()
|
.clean_on_escape()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let speech = cx.new(|cx| {
|
||||||
|
let state = SpeechState::new(cx)
|
||||||
|
.recognizer(speech::recognizer())
|
||||||
|
// Batch transcription of a long recording can take a while.
|
||||||
|
.stop_timeout(Duration::from_secs(180));
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let state = state.input(speech::input());
|
||||||
|
|
||||||
|
state
|
||||||
|
});
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
@@ -350,6 +368,34 @@ impl GroupPanel {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
cx.subscribe_in(&speech, window, |this, _, event, window, cx| {
|
||||||
|
match event {
|
||||||
|
SpeechEvent::Started => {
|
||||||
|
this.speech_started = Some(instant::Instant::now());
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
SpeechEvent::Final(text) => {
|
||||||
|
this.speech_started = None;
|
||||||
|
|
||||||
|
if !text.is_empty() {
|
||||||
|
this.input.update(cx, |input, cx| {
|
||||||
|
input.insert(text.clone(), window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SpeechEvent::Error(error) => {
|
||||||
|
this.speech_started = None;
|
||||||
|
window.push_notification(Notification::error(error.to_string()), cx);
|
||||||
|
}
|
||||||
|
SpeechEvent::Cancelled => {
|
||||||
|
this.speech_started = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| {
|
subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| {
|
||||||
if !matches!(event, GroupEvent::Sent(_)) {
|
if !matches!(event, GroupEvent::Sent(_)) {
|
||||||
this.sync(cx);
|
this.sync(cx);
|
||||||
@@ -385,6 +431,8 @@ impl GroupPanel {
|
|||||||
notice_dismissed: false,
|
notice_dismissed: false,
|
||||||
rendered_texts_by_id: BTreeMap::new(),
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
input,
|
input,
|
||||||
|
speech,
|
||||||
|
speech_started: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
uploading: false,
|
uploading: false,
|
||||||
tasks: smallvec![],
|
tasks: smallvec![],
|
||||||
@@ -554,22 +602,17 @@ impl GroupPanel {
|
|||||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let server = AppSettings::get_file_server(cx);
|
let server = AppSettings::get_file_server(cx);
|
||||||
|
|
||||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
let picker = pick_file(cx);
|
||||||
files: true,
|
|
||||||
directories: false,
|
|
||||||
multiple: false,
|
|
||||||
prompt: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
// Selecting no file means the prompt was cancelled
|
// Selecting no file means the prompt was cancelled
|
||||||
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
let Some(picked) = picker.await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
this.update(cx, |this, cx| this.set_uploading(true, cx))?;
|
this.update(cx, |this, cx| this.set_uploading(true, cx))?;
|
||||||
|
|
||||||
match upload(server, path, cx).await {
|
match upload_file(server, picked, cx).await {
|
||||||
Ok(url) => {
|
Ok(url) => {
|
||||||
this.update_in(cx, |this, _window, cx| {
|
this.update_in(cx, |this, _window, cx| {
|
||||||
this.attachments.push(url);
|
this.attachments.push(url);
|
||||||
@@ -986,7 +1029,10 @@ impl GroupPanel {
|
|||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
rendered_text(
|
rendered_text(
|
||||||
&message.content,
|
&message.content,
|
||||||
&message.mentions,
|
message
|
||||||
|
.mentions
|
||||||
|
.iter()
|
||||||
|
.map(|mention| (mention.range.clone(), mention.public_key)),
|
||||||
&message.emojis,
|
&message.emojis,
|
||||||
&directory,
|
&directory,
|
||||||
cx,
|
cx,
|
||||||
@@ -1019,6 +1065,17 @@ impl GroupPanel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn toggle_dictation(
|
||||||
|
&mut self,
|
||||||
|
_ev: &ToggleDictation,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.speech.update(cx, |speech, cx| {
|
||||||
|
speech.toggle(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let notice = self.group.upgrade().and_then(|group| {
|
let notice = self.group.upgrade().and_then(|group| {
|
||||||
let group = group.read(cx);
|
let group = group.read(cx);
|
||||||
@@ -1044,6 +1101,8 @@ impl GroupPanel {
|
|||||||
return self.render_notice(¬ice, cx);
|
return self.render_notice(¬ice, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dictating = self.speech.read(cx).status().is_active();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -1059,23 +1118,45 @@ impl GroupPanel {
|
|||||||
.items_end()
|
.items_end()
|
||||||
.child(
|
.child(
|
||||||
Button::new("upload")
|
Button::new("upload")
|
||||||
.icon(IconName::Upload)
|
.icon(ui::IconName::Paperclip)
|
||||||
.tooltip("Upload file")
|
.tooltip("Upload media")
|
||||||
.loading(self.uploading)
|
.loading(self.uploading)
|
||||||
.disabled(self.uploading)
|
.disabled(dictating || self.uploading)
|
||||||
.ghost()
|
.ghost()
|
||||||
.large()
|
|
||||||
.on_click(cx.listener(|this, _event, window, cx| {
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
this.upload(window, cx);
|
this.upload(window, cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.child(Textarea::new(&self.input).appearance(false).flex_1())
|
.when(dictating, |this| {
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.h_8()
|
||||||
|
.flex_1()
|
||||||
|
.gap_2()
|
||||||
|
.child(SpeechWaveform::new(&self.speech).small().flex_1())
|
||||||
|
.when_some(self.speech_started, |this, started| {
|
||||||
|
this.child(speech::elapsed(started, cx))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(!dictating, |this| {
|
||||||
|
this.child(
|
||||||
|
Textarea::new(&self.input)
|
||||||
|
.appearance(false)
|
||||||
|
.bg(cx.theme().input)
|
||||||
|
.small()
|
||||||
|
.min_h_8()
|
||||||
|
.py_1()
|
||||||
|
.flex_1(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(SpeechButton::new(&self.speech))
|
||||||
.child(
|
.child(
|
||||||
Button::new("send")
|
Button::new("send")
|
||||||
.icon(IconName::SendHorizontal)
|
.icon(ui::IconName::PaperPlaneFill)
|
||||||
.tooltip("Send")
|
.tooltip("Send")
|
||||||
.ghost()
|
.ghost()
|
||||||
.large()
|
.disabled(dictating || self.uploading)
|
||||||
.on_click(cx.listener(|this, _event, window, cx| {
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
this.send(window, cx);
|
this.send(window, cx);
|
||||||
})),
|
})),
|
||||||
@@ -1126,7 +1207,7 @@ impl GroupPanel {
|
|||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.max_w(px(140.))
|
.max_w(px(140.))
|
||||||
.truncate()
|
.truncate()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.text_color(cx.theme().foreground)
|
.text_color(cx.theme().foreground)
|
||||||
.child(author),
|
.child(author),
|
||||||
)
|
)
|
||||||
@@ -1140,7 +1221,7 @@ impl GroupPanel {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("cancel-reply")
|
Button::new("cancel-reply")
|
||||||
.icon(IconName::X)
|
.icon(ui::IconName::Close)
|
||||||
.tooltip("Cancel reply")
|
.tooltip("Cancel reply")
|
||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
@@ -1177,7 +1258,7 @@ impl GroupPanel {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new(SharedString::from(format!("remove-attachment-{ix}")))
|
Button::new(SharedString::from(format!("remove-attachment-{ix}")))
|
||||||
.icon(IconName::X)
|
.icon(ui::IconName::Close)
|
||||||
.tooltip("Remove")
|
.tooltip("Remove")
|
||||||
.ghost()
|
.ghost()
|
||||||
.xsmall()
|
.xsmall()
|
||||||
@@ -1273,6 +1354,7 @@ impl Render for GroupPanel {
|
|||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
|
.on_action(cx.listener(Self::toggle_dictation))
|
||||||
.child(self.render_encryption_notice(cx))
|
.child(self.render_encryption_notice(cx))
|
||||||
.child(self.render_banner(cx))
|
.child(self.render_banner(cx))
|
||||||
.child(self.render_pins(cx))
|
.child(self.render_pins(cx))
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use chat::Message;
|
use chat::Message;
|
||||||
use chat_ui::message::{MessageMedia, MessageRow, ReactionBar, ReplyPreview, group_reactions};
|
|
||||||
use common::TimestampExt;
|
use common::TimestampExt;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -7,19 +6,21 @@ use gpui::{
|
|||||||
WeakEntity, div,
|
WeakEntity, div,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem};
|
use gpui_kit::base::StyledExt as _;
|
||||||
|
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
|
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
|
||||||
use nip29::{Activity, Group, Reaction};
|
use nip29::{Activity, Group, Reaction};
|
||||||
use nostr_sdk::prelude::{EventId, Kind};
|
use nostr_sdk::prelude::{EventId, Kind};
|
||||||
use person::Directory;
|
use person::Directory;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
use ui::Avatar;
|
use ui::{
|
||||||
|
Avatar, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview, group_reactions,
|
||||||
|
react_menu,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::GroupPanel;
|
use crate::GroupPanel;
|
||||||
|
|
||||||
const REACTIONS: [&str; 6] = ["👍", "❤️", "😂", "🎉", "😮", "😢"];
|
|
||||||
|
|
||||||
pub(crate) struct Actions {
|
pub(crate) struct Actions {
|
||||||
pub group: WeakEntity<Group>,
|
pub group: WeakEntity<Group>,
|
||||||
pub panel: WeakEntity<GroupPanel>,
|
pub panel: WeakEntity<GroupPanel>,
|
||||||
@@ -60,6 +61,7 @@ pub(crate) fn render(
|
|||||||
.avatar(
|
.avatar(
|
||||||
Avatar::new(author.avatar())
|
Avatar::new(author.avatar())
|
||||||
.seed(author.avatar_seed())
|
.seed(author.avatar_seed())
|
||||||
|
.small()
|
||||||
.flex_shrink_0(),
|
.flex_shrink_0(),
|
||||||
)
|
)
|
||||||
.author(author.name())
|
.author(author.name())
|
||||||
@@ -87,6 +89,22 @@ pub(crate) fn render(
|
|||||||
let group = actions.group;
|
let group = actions.group;
|
||||||
let panel = actions.panel;
|
let panel = actions.panel;
|
||||||
|
|
||||||
|
// Reading the panel here would re-enter its ongoing update, so resolve
|
||||||
|
// the current user's custom emojis through the shared person directory.
|
||||||
|
let custom = nostr
|
||||||
|
.read(cx)
|
||||||
|
.current_user()
|
||||||
|
.map(|current_user| {
|
||||||
|
persons
|
||||||
|
.read(cx)
|
||||||
|
.person(¤t_user, cx)
|
||||||
|
.emojis()
|
||||||
|
.iter()
|
||||||
|
.map(|(shortcode, _)| format!(":{shortcode}:"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let row = div()
|
let row = div()
|
||||||
.id(SharedString::from(format!("message-menu-{}", id.to_hex())))
|
.id(SharedString::from(format!("message-menu-{}", id.to_hex())))
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -105,8 +123,20 @@ pub(crate) fn render(
|
|||||||
.submenu("React", window, cx, {
|
.submenu("React", window, cx, {
|
||||||
let group = group.clone();
|
let group = group.clone();
|
||||||
let panel = panel.clone();
|
let panel = panel.clone();
|
||||||
|
let custom = custom.clone();
|
||||||
|
|
||||||
move |menu, _window, cx| react_menu(menu, group.clone(), panel.clone(), id, cx)
|
move |menu, _window, _cx| {
|
||||||
|
let group = group.clone();
|
||||||
|
let panel = panel.clone();
|
||||||
|
|
||||||
|
react_menu(menu, REACTION_EMOJIS, &custom, move |emoji, _window, cx| {
|
||||||
|
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||||
|
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
|
||||||
|
}) {
|
||||||
|
log::warn!("nip29: sending a reaction failed: {error}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if !admin {
|
if !admin {
|
||||||
@@ -141,67 +171,6 @@ pub(crate) fn render(
|
|||||||
row.into_any_element()
|
row.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn react_menu(
|
|
||||||
menu: PopupMenu,
|
|
||||||
group: WeakEntity<Group>,
|
|
||||||
panel: WeakEntity<GroupPanel>,
|
|
||||||
id: EventId,
|
|
||||||
cx: &App,
|
|
||||||
) -> PopupMenu {
|
|
||||||
let menu = REACTIONS.iter().fold(menu, |menu, emoji| {
|
|
||||||
let emoji = *emoji;
|
|
||||||
let group = group.clone();
|
|
||||||
let panel = panel.clone();
|
|
||||||
|
|
||||||
menu.item(
|
|
||||||
PopupMenuItem::new(emoji).on_click(move |_event, _window, cx| {
|
|
||||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
|
||||||
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
|
|
||||||
}) {
|
|
||||||
log::warn!("nip29: sending a reaction failed: {error}");
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
let emojis = panel
|
|
||||||
.upgrade()
|
|
||||||
.and_then(|panel| {
|
|
||||||
let current_user = NostrRegistry::global(cx).read(cx).current_user()?;
|
|
||||||
Some(
|
|
||||||
panel
|
|
||||||
.read(cx)
|
|
||||||
.directory
|
|
||||||
.read(cx)
|
|
||||||
.person(¤t_user, cx)
|
|
||||||
.emojis()
|
|
||||||
.to_vec(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if emojis.is_empty() {
|
|
||||||
return menu;
|
|
||||||
}
|
|
||||||
|
|
||||||
let menu = menu.separator();
|
|
||||||
emojis.into_iter().fold(menu, |menu, (shortcode, _)| {
|
|
||||||
let emoji = format!(":{shortcode}:");
|
|
||||||
let group = group.clone();
|
|
||||||
let panel = panel.clone();
|
|
||||||
|
|
||||||
menu.item(
|
|
||||||
PopupMenuItem::new(emoji.clone()).on_click(move |_event, _window, cx| {
|
|
||||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
|
||||||
panel.react(group.clone(), id, emoji.clone(), window, cx);
|
|
||||||
}) {
|
|
||||||
log::warn!("nip29: sending a reaction failed: {error}");
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn render_activity(
|
pub(crate) fn render_activity(
|
||||||
ix: usize,
|
ix: usize,
|
||||||
activity: &Activity,
|
activity: &Activity,
|
||||||
@@ -217,7 +186,7 @@ pub(crate) fn render_activity(
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.py_1()
|
.py_1()
|
||||||
.px_3()
|
.px_3()
|
||||||
.hover(|this| this.bg(cx.theme().primary))
|
.hover(|this| this.bg(cx.theme().list_hover))
|
||||||
.child(badge(activity, cx))
|
.child(badge(activity, cx))
|
||||||
.when(!hide_avatar, |this| {
|
.when(!hide_avatar, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
@@ -232,7 +201,7 @@ pub(crate) fn render_activity(
|
|||||||
.min_w_0()
|
.min_w_0()
|
||||||
.truncate()
|
.truncate()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.child(subject.name()),
|
.child(subject.name()),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::dialog::DialogButtonProps;
|
use gpui_kit::component::dialog::DialogButtonProps;
|
||||||
use gpui_kit::component::form::field as form_field;
|
|
||||||
use gpui_kit::component::input::InputState;
|
use gpui_kit::component::input::InputState;
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
use gpui_kit::component::switch::Switch;
|
use gpui_kit::component::switch::Switch;
|
||||||
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use nip29::{Group, GroupId, GroupMetadata};
|
use nip29::{Group, GroupId, GroupMetadata};
|
||||||
use nostr_sdk::prelude::RelayUrl;
|
use nostr_sdk::prelude::RelayUrl;
|
||||||
|
use ui::settings_row;
|
||||||
|
|
||||||
use crate::util::field;
|
use crate::util::field;
|
||||||
|
|
||||||
@@ -126,57 +126,45 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.child(
|
.child(settings_row(
|
||||||
form_field()
|
"Private",
|
||||||
.label("Private")
|
"Only members can read group messages",
|
||||||
.description("Only members can read group messages")
|
|
||||||
.child(
|
|
||||||
Switch::new("private")
|
Switch::new("private")
|
||||||
.checked(current.private)
|
.checked(current.private)
|
||||||
.on_click(toggle(&flags, |flags| &mut flags.private)),
|
.on_click(toggle(&flags, |flags| &mut flags.private)),
|
||||||
),
|
cx,
|
||||||
)
|
))
|
||||||
.child(
|
.child(settings_row(
|
||||||
form_field()
|
"Restricted",
|
||||||
.label("Restricted")
|
"Only members can post messages",
|
||||||
.description("Only members can post messages")
|
|
||||||
.child(
|
|
||||||
Switch::new("restricted")
|
Switch::new("restricted")
|
||||||
.checked(current.restricted)
|
.checked(current.restricted)
|
||||||
.on_click(toggle(&flags, |flags| {
|
.on_click(toggle(&flags, |flags| &mut flags.restricted)),
|
||||||
&mut flags.restricted
|
cx,
|
||||||
})),
|
))
|
||||||
),
|
.child(settings_row(
|
||||||
)
|
"Hidden",
|
||||||
.child(
|
"Hidden from non-members, not discoverable",
|
||||||
form_field()
|
|
||||||
.label("Hidden")
|
|
||||||
.description("Hidden from non-members, not discoverable")
|
|
||||||
.child(
|
|
||||||
Switch::new("hidden")
|
Switch::new("hidden")
|
||||||
.checked(current.hidden)
|
.checked(current.hidden)
|
||||||
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
|
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
|
||||||
),
|
cx,
|
||||||
)
|
))
|
||||||
.child(
|
.child(settings_row(
|
||||||
form_field()
|
"Closed",
|
||||||
.label("Closed")
|
"Join requests are ignored (invite-only)",
|
||||||
.description("Join requests are ignored (invite-only)")
|
|
||||||
.child(
|
|
||||||
Switch::new("closed")
|
Switch::new("closed")
|
||||||
.checked(current.closed)
|
.checked(current.closed)
|
||||||
.on_click(toggle(&flags, |flags| &mut flags.closed)),
|
.on_click(toggle(&flags, |flags| &mut flags.closed)),
|
||||||
),
|
cx,
|
||||||
)
|
))
|
||||||
.child(
|
.child(settings_row(
|
||||||
form_field()
|
"LiveKit",
|
||||||
.label("LiveKit")
|
if supported {
|
||||||
.description(if supported {
|
|
||||||
"Announce audio and video support."
|
"Announce audio and video support."
|
||||||
} else {
|
} else {
|
||||||
"The relay doesn't announce LiveKit support."
|
"The relay doesn't announce LiveKit support."
|
||||||
})
|
},
|
||||||
.child(
|
|
||||||
Switch::new("livekit").checked(current.livekit).on_click({
|
Switch::new("livekit").checked(current.livekit).on_click({
|
||||||
let flags = flags.clone();
|
let flags = flags.clone();
|
||||||
let livekit = livekit.clone();
|
let livekit = livekit.clone();
|
||||||
@@ -191,8 +179,8 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
cx,
|
||||||
),
|
)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.on_ok({
|
.on_ok({
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match (current, previous) {
|
match (current, previous) {
|
||||||
(Row::Message(current), Row::Message(previous)) => chat_ui::message::opens_run(
|
(Row::Message(current), Row::Message(previous)) => ui::opens_run(
|
||||||
Some((previous.author, previous.created_at.as_secs())),
|
Some((previous.author, previous.created_at.as_secs())),
|
||||||
(current.author, current.created_at.as_secs()),
|
(current.author, current.created_at.as_secs()),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use gpui::{
|
|||||||
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
|
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
|
||||||
SharedString, Styled, Subscription, Task, Window, div, relative,
|
SharedString, Styled, Subscription, Task, Window, div, relative,
|
||||||
};
|
};
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::Button;
|
use gpui_kit::component::button::Button;
|
||||||
use gpui_kit::component::notification::{Notification, NotificationType};
|
use gpui_kit::component::notification::{Notification, NotificationType};
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
@@ -17,7 +18,7 @@ use nostr_sdk::prelude::*;
|
|||||||
use person::Directory;
|
use person::Directory;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
|
use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
|
||||||
use ui::Avatar;
|
use ui::Avatar;
|
||||||
|
|
||||||
const IDENTIFIER: &str = "coop:device";
|
const IDENTIFIER: &str = "coop:device";
|
||||||
@@ -220,26 +221,39 @@ impl DeviceRegistry {
|
|||||||
cx.emit(DeviceEvent::Set);
|
cx.emit(DeviceEvent::Set);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backup the encryption's secret key to a file
|
/// Backup the encryption's secret key, returning the `nsec1...` content
|
||||||
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
|
pub fn backup_bytes(&self, cx: &App) -> Task<Result<String, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let keys = get_keys(&client, &signer).await?;
|
let keys = get_keys(&client, &signer).await?;
|
||||||
let content = keys.secret_key().to_bech32()?;
|
Ok(keys.secret_key().to_bech32()?)
|
||||||
|
})
|
||||||
if cfg!(target_arch = "wasm32") {
|
|
||||||
return Err(anyhow!("Not supported"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Backup the encryption's secret key to a file
|
||||||
|
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
|
||||||
|
let backup = self.backup_bytes(cx);
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
return cx.background_spawn(async move {
|
||||||
|
let content = backup.await?;
|
||||||
smol::fs::write(path, &content).await?;
|
smol::fs::write(path, &content).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = path;
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
backup.await?;
|
||||||
|
Err(anyhow!("File backups are not supported on web; copy the key from the backup panel instead."))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get device announcement for current user
|
/// Get device announcement for current user
|
||||||
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
||||||
@@ -743,7 +757,7 @@ impl DeviceRegistry {
|
|||||||
.text_xs()
|
.text_xs()
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child(SharedString::from("From:")),
|
.child(SharedString::from("From:")),
|
||||||
@@ -773,7 +787,7 @@ impl DeviceRegistry {
|
|||||||
.text_xs()
|
.text_xs()
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child(SharedString::from("Code:")),
|
.child(SharedString::from("Code:")),
|
||||||
@@ -826,10 +840,10 @@ fn authorization_code(public_key: &PublicKey) -> SharedString {
|
|||||||
|
|
||||||
/// Get or create new app keys (async, returns a task)
|
/// Get or create new app keys (async, returns a task)
|
||||||
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
|
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
|
||||||
let read = cx.read_credentials(CLIENT_NAME);
|
let read = credentials::read_app_keys(cx);
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
cx.spawn(async move |cx| {
|
||||||
if let Ok(Some((_, secret))) = read.await
|
if let Ok(Some(secret)) = read.await
|
||||||
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
|
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
|
||||||
{
|
{
|
||||||
return Ok(keys);
|
return Ok(keys);
|
||||||
@@ -837,14 +851,13 @@ fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
|
|||||||
|
|
||||||
// No stored keys found or invalid — generate new ones
|
// No stored keys found or invalid — generate new ones
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
let user = keys.public_key().to_hex();
|
|
||||||
let secret = keys.secret_key().to_secret_bytes();
|
let secret = keys.secret_key().to_secret_bytes();
|
||||||
|
|
||||||
cx.update(|cx| {
|
cx.update(|cx| {
|
||||||
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
|
let write = credentials::write_app_keys(cx, &secret);
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
if let Err(e) = write.await {
|
if let Err(e) = write.await {
|
||||||
log::error!("Keyring not available or panic: {e}")
|
log::error!("Failed to persist app keys: {e}")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
|||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
|
|
||||||
|
futures.workspace = true
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ use std::sync::RwLock;
|
|||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use common::emojis_from_tags;
|
use common::emojis_from_tags;
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
use futures::{FutureExt, pin_mut};
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, BackgroundExecutor, Context, Entity, EventEmitter, Global, Subscription, Task,
|
||||||
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -98,8 +101,9 @@ impl PersonRegistry {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let client3 = client.clone();
|
let client3 = client.clone();
|
||||||
|
let executor = cx.background_executor().clone();
|
||||||
tasks.push(cx.background_spawn(async move {
|
tasks.push(cx.background_spawn(async move {
|
||||||
Self::handle_requests(&client3, &metadata_rx).await;
|
Self::handle_requests(&client3, &metadata_rx, executor).await;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
tasks.push(cx.spawn(async move |this, cx| {
|
tasks.push(cx.spawn(async move |this, cx| {
|
||||||
@@ -193,30 +197,45 @@ impl PersonRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle request for metadata
|
/// Handle request for metadata
|
||||||
async fn handle_requests(client: &Client, rx: &flume::Receiver<PublicKey>) {
|
async fn handle_requests(
|
||||||
|
client: &Client,
|
||||||
|
rx: &flume::Receiver<PublicKey>,
|
||||||
|
executor: BackgroundExecutor,
|
||||||
|
) {
|
||||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match flume::Selector::new()
|
let timer = executor.timer(Duration::from_secs(TIMEOUT)).fuse();
|
||||||
.recv(rx, |result| result.ok())
|
pin_mut!(timer);
|
||||||
.wait_timeout(Duration::from_secs(TIMEOUT))
|
|
||||||
{
|
let request = futures::select! {
|
||||||
Ok(Some(public_key)) => {
|
result = rx.recv_async().fuse() => match result {
|
||||||
batch.insert(public_key);
|
Ok(public_key) => public_key,
|
||||||
// Process the batch if it's full
|
Err(_) => {
|
||||||
if batch.len() >= 20
|
|
||||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
|
||||||
{
|
|
||||||
log::warn!("Failed to get metadata batch: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if !batch.is_empty()
|
if !batch.is_empty()
|
||||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||||
{
|
{
|
||||||
log::warn!("Failed to get metadata batch: {e}");
|
log::warn!("Failed to get metadata batch: {e}");
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
() = timer => {
|
||||||
|
if !batch.is_empty()
|
||||||
|
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||||
|
{
|
||||||
|
log::warn!("Failed to get metadata batch: {e}");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
batch.insert(request);
|
||||||
|
// Process the batch if it's full
|
||||||
|
if batch.len() >= 20
|
||||||
|
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||||
|
{
|
||||||
|
log::warn!("Failed to get metadata batch: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use gpui::{Context, EventEmitter, Task};
|
|||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::NostrRegistry;
|
use state::{BOOTSTRAP_RELAYS, NostrRegistry};
|
||||||
|
|
||||||
/// Disable scoring when the follow set exceeds this size
|
/// Disable scoring when the follow set exceeds this size
|
||||||
const MAX_FOLLOWS: usize = 2000;
|
const MAX_FOLLOWS: usize = 2000;
|
||||||
@@ -14,6 +14,10 @@ const MAX_TAGS_PER_LIST: usize = 5000;
|
|||||||
const SYNC_INTERVAL: u64 = 15 * 60;
|
const SYNC_INTERVAL: u64 = 15 * 60;
|
||||||
/// Longest wait between retries while the graph has no data yet
|
/// Longest wait between retries while the graph has no data yet
|
||||||
const MAX_RETRY_WAIT: u64 = 60;
|
const MAX_RETRY_WAIT: u64 = 60;
|
||||||
|
/// Time for a relay to prove negentropy support before the reconcile gives up
|
||||||
|
const SYNC_INITIAL_TIMEOUT: u64 = 30;
|
||||||
|
/// Longest silence from a relay before the reconcile gives up
|
||||||
|
const SYNC_IDLE_TIMEOUT: u64 = 60;
|
||||||
|
|
||||||
/// The web of trust graph changed
|
/// The web of trust graph changed
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -77,6 +81,36 @@ impl Snapshot {
|
|||||||
|
|
||||||
snapshot
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the snapshot from the contact lists the local database holds
|
||||||
|
async fn load(client: &Client, self_key: PublicKey) -> Option<Self> {
|
||||||
|
let own = stored_contact_list(client, self_key).await?;
|
||||||
|
let follows: HashSet<PublicKey> = own.tags.public_keys().take(MAX_FOLLOWS + 1).collect();
|
||||||
|
|
||||||
|
let events: Vec<Event> = if follows.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
let authors: Vec<PublicKey> = follows.iter().copied().collect();
|
||||||
|
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
|
||||||
|
|
||||||
|
client
|
||||||
|
.database()
|
||||||
|
.query(filter)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshot = Self::build(self_key, follows, events);
|
||||||
|
log::debug!(
|
||||||
|
"wot loaded from database: {} follows, {} scored keys",
|
||||||
|
snapshot.follows.len(),
|
||||||
|
snapshot.scores.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(snapshot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<WoTEvent> for WebOfTrust {}
|
impl EventEmitter<WoTEvent> for WebOfTrust {}
|
||||||
@@ -88,8 +122,10 @@ impl EventEmitter<WoTEvent> for WebOfTrust {}
|
|||||||
pub struct WebOfTrust {
|
pub struct WebOfTrust {
|
||||||
/// Current user's public key
|
/// Current user's public key
|
||||||
self_key: Option<PublicKey>,
|
self_key: Option<PublicKey>,
|
||||||
/// Latest built snapshot, none until the first refresh completes
|
/// Latest built snapshot, none until the first load completes
|
||||||
snapshot: Option<Snapshot>,
|
snapshot: Option<Snapshot>,
|
||||||
|
/// Whether a reconciliation with the relays is in flight
|
||||||
|
syncing: bool,
|
||||||
/// In-flight refresh, replaced by newer ones
|
/// In-flight refresh, replaced by newer ones
|
||||||
refresh_task: Option<Task<()>>,
|
refresh_task: Option<Task<()>>,
|
||||||
/// Keeps the periodic reconciliation loop alive
|
/// Keeps the periodic reconciliation loop alive
|
||||||
@@ -110,9 +146,9 @@ impl WebOfTrust {
|
|||||||
|
|
||||||
let mut has_data = false;
|
let mut has_data = false;
|
||||||
|
|
||||||
// Ignore errors, the next refresh will retry
|
// Ignore errors, the next sync will retry
|
||||||
let _ = this.update(cx, |this, cx| {
|
let _ = this.update(cx, |this, cx| {
|
||||||
this.refresh(cx);
|
this.sync(cx);
|
||||||
has_data = this.has_data();
|
has_data = this.has_data();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,34 +169,14 @@ impl WebOfTrust {
|
|||||||
Self {
|
Self {
|
||||||
self_key: None,
|
self_key: None,
|
||||||
snapshot: None,
|
snapshot: None,
|
||||||
|
syncing: false,
|
||||||
refresh_task: None,
|
refresh_task: None,
|
||||||
_tasks: tasks,
|
_tasks: tasks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the graph for the current account
|
/// Load the graph for the current account from the local database
|
||||||
pub(crate) fn hydrate(&mut self, cx: &mut Context<Self>) {
|
pub(crate) fn hydrate(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let Some(self_key) = nostr.read(cx).current_user() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
self.self_key = Some(self_key);
|
|
||||||
self.refresh(cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the graph on sign out or account switch
|
|
||||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
|
||||||
self.self_key = None;
|
|
||||||
self.snapshot = None;
|
|
||||||
self.refresh_task = None;
|
|
||||||
|
|
||||||
cx.emit(WoTEvent);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reconcile the follow set's contact lists with the relays and rebuild.
|
|
||||||
fn refresh(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
@@ -168,6 +184,56 @@ impl WebOfTrust {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
self.self_key = Some(self_key);
|
||||||
|
// Replacing the task below cancels any in-flight work
|
||||||
|
self.syncing = false;
|
||||||
|
|
||||||
|
self.refresh_task = Some(cx.spawn(async move |this, cx| {
|
||||||
|
let Some(snapshot) = Snapshot::load(&client, self_key).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.snapshot = Some(snapshot);
|
||||||
|
cx.emit(WoTEvent);
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the graph on sign out or account switch
|
||||||
|
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.self_key = None;
|
||||||
|
self.snapshot = None;
|
||||||
|
self.syncing = false;
|
||||||
|
self.refresh_task = None;
|
||||||
|
|
||||||
|
cx.emit(WoTEvent);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconcile the follow set's contact lists with the bootstrap relays and rebuild.
|
||||||
|
fn sync(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.syncing {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let Some(self_key) = nostr.read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The defaults are too short for relays streaming large batches
|
||||||
|
let opts = SyncOptions::new()
|
||||||
|
.initial_timeout(Duration::from_secs(SYNC_INITIAL_TIMEOUT))
|
||||||
|
.idle_timeout(Duration::from_secs(SYNC_IDLE_TIMEOUT));
|
||||||
|
|
||||||
|
self.syncing = true;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
self.refresh_task = Some(cx.spawn(async move |this, cx| {
|
self.refresh_task = Some(cx.spawn(async move |this, cx| {
|
||||||
// Scope the reconcile to the follow set known so far
|
// Scope the reconcile to the follow set known so far
|
||||||
let known: HashSet<PublicKey> = stored_contact_list(&client, self_key)
|
let known: HashSet<PublicKey> = stored_contact_list(&client, self_key)
|
||||||
@@ -178,60 +244,37 @@ impl WebOfTrust {
|
|||||||
let mut authors: Vec<PublicKey> = known.iter().copied().collect();
|
let mut authors: Vec<PublicKey> = known.iter().copied().collect();
|
||||||
authors.push(self_key);
|
authors.push(self_key);
|
||||||
|
|
||||||
// Negentropy transfers only the lists the relays hold
|
|
||||||
// and the database is missing
|
|
||||||
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
|
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
|
||||||
if let Err(error) = client.sync(filter).await {
|
match client.sync(filter).with(BOOTSTRAP_RELAYS).opts(opts).await {
|
||||||
log::warn!("Failed to reconcile contact lists: {error}");
|
Ok(output) => {
|
||||||
|
for (url, error) in output.failed {
|
||||||
|
log::warn!("Failed to reconcile contact lists with {url}: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => log::warn!("Failed to reconcile contact lists: {error}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// The follow set after the reconcile
|
// The follow set after the reconcile
|
||||||
let Some(own) = stored_contact_list(&client, self_key).await else {
|
let snapshot = Snapshot::load(&client, self_key).await;
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let follows: HashSet<PublicKey> =
|
|
||||||
own.tags.public_keys().take(MAX_FOLLOWS + 1).collect();
|
|
||||||
|
|
||||||
let mut authors: Vec<PublicKey> = follows.iter().copied().collect();
|
|
||||||
authors.push(self_key);
|
|
||||||
|
|
||||||
let events: Vec<Event> = if follows.is_empty() {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
|
|
||||||
client
|
|
||||||
.database()
|
|
||||||
.query(filter)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
let snapshot = Snapshot::build(self_key, follows.clone(), events);
|
|
||||||
log::debug!(
|
|
||||||
"Web of trust rebuilt: {} follows, {} scored keys",
|
|
||||||
snapshot.follows.len(),
|
|
||||||
snapshot.scores.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Newly followed authors were not part of the reconcile
|
|
||||||
let grew = !known.is_superset(&follows);
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
|
this.syncing = false;
|
||||||
|
|
||||||
|
if let Some(snapshot) = snapshot {
|
||||||
|
// Newly followed authors were not part of the reconcile
|
||||||
|
let grew = !known.is_superset(&snapshot.follows);
|
||||||
|
|
||||||
this.snapshot = Some(snapshot);
|
this.snapshot = Some(snapshot);
|
||||||
cx.emit(WoTEvent);
|
cx.emit(WoTEvent);
|
||||||
|
|
||||||
|
if grew {
|
||||||
|
this.sync(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
if grew {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.refresh(cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +316,11 @@ impl WebOfTrust {
|
|||||||
.is_some_and(|snapshot| !snapshot.disabled)
|
.is_some_and(|snapshot| !snapshot.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a reconciliation with the relays is in flight
|
||||||
|
pub fn is_syncing(&self) -> bool {
|
||||||
|
self.syncing
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the snapshot covers a follow set
|
/// Whether the snapshot covers a follow set
|
||||||
fn has_data(&self) -> bool {
|
fn has_data(&self) -> bool {
|
||||||
self.snapshot
|
self.snapshot
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
|
||||||
|
/// Browser storage key for the settings JSON
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
const SETTINGS_KEY: &str = "coop.settings";
|
||||||
|
|
||||||
/// The user's appearance preference, persisted with the rest of the settings.
|
/// The user's appearance preference, persisted with the rest of the settings.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
pub enum AppearanceMode {
|
pub enum AppearanceMode {
|
||||||
@@ -31,26 +36,66 @@ impl AppearanceMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The default base font size for the interface
|
||||||
|
pub const DEFAULT_FONT_SIZE: f32 = 16.0;
|
||||||
|
|
||||||
|
/// User tuning applied on top of the native OS theme
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ThemeSettings {
|
||||||
|
/// The base UI font size in pixels
|
||||||
|
pub font_size: f32,
|
||||||
|
/// Draw a ring around focused controls
|
||||||
|
pub focus_ring: bool,
|
||||||
|
/// Corner radius for general elements; follows the OS theme when unset
|
||||||
|
pub radius: Option<f32>,
|
||||||
|
/// Corner radius for large elements like dialogs; follows the OS theme when unset
|
||||||
|
pub radius_lg: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ThemeSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
font_size: DEFAULT_FONT_SIZE,
|
||||||
|
focus_ring: false,
|
||||||
|
radius: None,
|
||||||
|
radius_lg: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the persisted appearance mode synchronously.
|
/// Read the persisted appearance mode synchronously.
|
||||||
///
|
fn persisted_settings() -> Option<Settings> {
|
||||||
/// The async load fills the global settings later; the native theme must be
|
let content = persisted_content();
|
||||||
/// applied before the first window opens, so boot reads the mode straight
|
content.and_then(|content| serde_json::from_str(&content).ok())
|
||||||
/// from the settings file instead.
|
}
|
||||||
pub fn persisted_appearance_mode() -> AppearanceMode {
|
|
||||||
|
/// The persisted settings JSON, from the config file or browser storage
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
fn persisted_content() -> Option<String> {
|
||||||
let path = config_dir().join(".settings");
|
let path = config_dir().join(".settings");
|
||||||
if let Ok(content) = std::fs::read_to_string(path)
|
std::fs::read_to_string(path).ok()
|
||||||
&& let Ok(settings) = serde_json::from_str::<Settings>(&content)
|
|
||||||
{
|
|
||||||
return settings.appearance_mode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn persisted_content() -> Option<String> {
|
||||||
|
common::web_store::get(SETTINGS_KEY).ok().flatten()
|
||||||
}
|
}
|
||||||
AppearanceMode::default()
|
|
||||||
|
pub fn persisted_appearance_mode() -> AppearanceMode {
|
||||||
|
persisted_settings()
|
||||||
|
.map(|settings| settings.appearance_mode)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the persisted theme tuning synchronously.
|
||||||
|
pub fn persisted_theme() -> ThemeSettings {
|
||||||
|
persisted_settings()
|
||||||
|
.map(|settings| settings.theme)
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||||
@@ -109,6 +154,7 @@ setting_accessors! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setting_getters! {
|
setting_getters! {
|
||||||
|
pub theme: ThemeSettings,
|
||||||
pub pinned_rooms: Vec<u64>,
|
pub pinned_rooms: Vec<u64>,
|
||||||
pub pinned_groups: Vec<String>,
|
pub pinned_groups: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -179,6 +225,9 @@ pub struct Settings {
|
|||||||
/// Hide the notice that messages in public groups are not encrypted
|
/// Hide the notice that messages in public groups are not encrypted
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hide_group_notice: bool,
|
pub hide_group_notice: bool,
|
||||||
|
/// Tuning applied on top of the native OS theme
|
||||||
|
#[serde(default)]
|
||||||
|
pub theme: ThemeSettings,
|
||||||
/// Show an image behind chat and group panels
|
/// Show an image behind chat and group panels
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub panel_background: bool,
|
pub panel_background: bool,
|
||||||
@@ -206,6 +255,7 @@ impl Default for Settings {
|
|||||||
wot_auto_trust: true,
|
wot_auto_trust: true,
|
||||||
nip4e: false,
|
nip4e: false,
|
||||||
hide_group_notice: false,
|
hide_group_notice: false,
|
||||||
|
theme: ThemeSettings::default(),
|
||||||
panel_background: true,
|
panel_background: true,
|
||||||
panel_background_path: None,
|
panel_background_path: None,
|
||||||
trusted_relays: vec![],
|
trusted_relays: vec![],
|
||||||
@@ -270,6 +320,34 @@ impl AppSettings {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Edit the theme tuning
|
||||||
|
pub fn update_theme(edit: impl FnOnce(&mut ThemeSettings), cx: &mut App) {
|
||||||
|
Self::global(cx).update(cx, |this, cx| {
|
||||||
|
this.inner.update(cx, |inner, cx| {
|
||||||
|
edit(&mut inner.theme);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the persisted theme tuning on top of the active theme
|
||||||
|
pub fn apply_theme_style(cx: &mut App) {
|
||||||
|
use gpui_kit::component::Theme;
|
||||||
|
|
||||||
|
let theme = Self::get_theme(cx);
|
||||||
|
let styled = Theme::global_mut(cx);
|
||||||
|
styled.font_size = px(theme.font_size);
|
||||||
|
styled.focus_ring = theme.focus_ring;
|
||||||
|
if let Some(radius) = theme.radius {
|
||||||
|
styled.radius = px(radius);
|
||||||
|
}
|
||||||
|
if let Some(radius_lg) = theme.radius_lg {
|
||||||
|
styled.radius_lg = px(radius_lg);
|
||||||
|
}
|
||||||
|
Theme::sync_base(cx);
|
||||||
|
cx.refresh_windows();
|
||||||
|
}
|
||||||
|
|
||||||
/// Load settings
|
/// Load settings
|
||||||
fn load(&mut self, cx: &mut Context<Self>) {
|
fn load(&mut self, cx: &mut Context<Self>) {
|
||||||
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
||||||
@@ -280,6 +358,14 @@ impl AppSettings {
|
|||||||
return Ok(serde_json::from_str(&content)?);
|
return Ok(serde_json::from_str(&content)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(content) = common::web_store::get(SETTINGS_KEY)? {
|
||||||
|
return Ok(serde_json::from_str(&content)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Err(anyhow!("Not found"))
|
Err(anyhow!("Not found"))
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -312,6 +398,11 @@ impl AppSettings {
|
|||||||
smol::fs::write(&path, content).await.ok();
|
smol::fs::write(&path, content).await.ok();
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) {
|
||||||
|
log::warn!("failed to persist settings: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,3 +473,27 @@ impl AppSettings {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Settings written before theme tuning existed must keep loading.
|
||||||
|
#[test]
|
||||||
|
fn settings_without_theme_keep_defaults() {
|
||||||
|
let settings: Settings = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"appearance_mode": "Dark",
|
||||||
|
"hide_avatar": false,
|
||||||
|
"screening": true,
|
||||||
|
"wot_auto_trust": true,
|
||||||
|
"nip4e": false,
|
||||||
|
"trusted_relays": [],
|
||||||
|
"file_server": "https://nostr.download/"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(settings.theme, ThemeSettings::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "speech"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
gpui.workspace = true
|
||||||
|
gpui-kit = { workspace = true, features = ["speech"] }
|
||||||
|
flume.workspace = true
|
||||||
|
instant.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
parakeet-rs = "=0.3.8"
|
||||||
|
ort = "=2.0.0-rc.13"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = [
|
||||||
|
"blocking",
|
||||||
|
"rustls-tls",
|
||||||
|
] }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
js-sys = "0.3"
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
web-sys = { version = "0.3", features = ["Window"] }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod model;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
mod parakeet;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod web;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
Animation, AnimationExt as _, AnyElement, App, IntoElement as _, ParentElement as _,
|
||||||
|
Styled as _, div,
|
||||||
|
};
|
||||||
|
use gpui_kit::component::ActiveTheme as _;
|
||||||
|
use gpui_kit::component::speech::SpeechRecognizer;
|
||||||
|
|
||||||
|
/// A ticking `M:SS` counter for the dictation in progress.
|
||||||
|
pub fn elapsed(started: instant::Instant, cx: &App) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.with_animation(
|
||||||
|
"dictation-elapsed",
|
||||||
|
Animation::new(Duration::from_secs(1))
|
||||||
|
.repeat()
|
||||||
|
.with_max_fps(1.),
|
||||||
|
move |counter, _| counter.child(format_elapsed(started)),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_elapsed(started: instant::Instant) -> String {
|
||||||
|
let seconds = started.elapsed().as_secs();
|
||||||
|
format!("{}:{:02}", seconds / 60, seconds % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recognizer backing the composer's microphone button.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn recognizer() -> impl SpeechRecognizer {
|
||||||
|
parakeet::ParakeetRecognizer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recognizer backing the composer's microphone button.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn recognizer() -> impl SpeechRecognizer {
|
||||||
|
web::WebSpeechRecognizer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The audio source backing the composer's microphone button on the web.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn input() -> impl gpui_kit::component::speech::AudioInput {
|
||||||
|
web::SelfCapturedInput
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"repository": "istupakov/parakeet-tdt-0.6b-v3-onnx",
|
||||||
|
"revision": "8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce",
|
||||||
|
"source": "nvidia/parakeet-tdt-0.6b-v3",
|
||||||
|
"license": "CC-BY-4.0",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "decoder_joint-model.int8.onnx",
|
||||||
|
"size": 18202004,
|
||||||
|
"sha256": "eea7483ee3d1a30375daedc8ed83e3960c91b098812127a0d99d1c8977667a70"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "encoder-model.int8.onnx",
|
||||||
|
"size": 652183999,
|
||||||
|
"sha256": "6139d2fa7e1b086097b277c7149725edbab89cc7c7ae64b23c741be4055aff09"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vocab.txt",
|
||||||
|
"size": 93939,
|
||||||
|
"sha256": "d58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::{fs, io};
|
||||||
|
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// How the model is currently available.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Status {
|
||||||
|
NotInstalled,
|
||||||
|
Downloading { downloaded: u64, total: u64 },
|
||||||
|
Installed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Status {
|
||||||
|
pub fn is_downloading(self) -> bool {
|
||||||
|
matches!(self, Status::Downloading { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Manifest {
|
||||||
|
repository: String,
|
||||||
|
revision: String,
|
||||||
|
files: Vec<Artifact>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Artifact {
|
||||||
|
name: String,
|
||||||
|
size: u64,
|
||||||
|
sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest() -> &'static Manifest {
|
||||||
|
static MANIFEST: OnceLock<Manifest> = OnceLock::new();
|
||||||
|
|
||||||
|
MANIFEST.get_or_init(|| {
|
||||||
|
serde_json::from_str(include_str!("model.json")).expect("pinned model manifest")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory the model artifacts are downloaded into.
|
||||||
|
pub fn dir() -> PathBuf {
|
||||||
|
common::config_dir()
|
||||||
|
.join("speech")
|
||||||
|
.join("parakeet-tdt-0.6b-v3")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total download size in bytes.
|
||||||
|
pub fn total_size() -> u64 {
|
||||||
|
manifest().files.iter().map(|file| file.size).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The revision of the installed model, if its artifacts are complete.
|
||||||
|
fn installed_revision() -> Option<String> {
|
||||||
|
let manifest = manifest();
|
||||||
|
let dir = dir();
|
||||||
|
|
||||||
|
let complete = manifest.files.iter().all(|file| {
|
||||||
|
fs::metadata(dir.join(&file.name)).is_ok_and(|metadata| metadata.len() == file.size)
|
||||||
|
});
|
||||||
|
|
||||||
|
if complete
|
||||||
|
&& fs::read_to_string(dir.join("verified"))
|
||||||
|
.is_ok_and(|revision| revision == manifest.revision)
|
||||||
|
{
|
||||||
|
Some(manifest.revision.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the model is ready to load.
|
||||||
|
pub fn installed() -> bool {
|
||||||
|
status() == Status::Installed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current status, reconciling the download state with the disk.
|
||||||
|
pub fn status() -> Status {
|
||||||
|
let state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
|
||||||
|
if let Download::Running {
|
||||||
|
downloaded, total, ..
|
||||||
|
} = state.download
|
||||||
|
{
|
||||||
|
return Status::Downloading { downloaded, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
if installed_revision().is_some() {
|
||||||
|
Status::Installed
|
||||||
|
} else {
|
||||||
|
Status::NotInstalled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the last download failure, if it has not been reported yet.
|
||||||
|
pub fn take_error() -> Option<String> {
|
||||||
|
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
state.error.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start downloading the model, unless a download is already running.
|
||||||
|
pub fn start_download() {
|
||||||
|
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
|
||||||
|
if matches!(state.download, Download::Running { .. }) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
state.error = None;
|
||||||
|
state.download = Download::Running {
|
||||||
|
downloaded: 0,
|
||||||
|
total: total_size(),
|
||||||
|
cancel: cancel.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = std::thread::Builder::new()
|
||||||
|
.name("speech-model-download".into())
|
||||||
|
.spawn(move || download(cancel))
|
||||||
|
{
|
||||||
|
state.download = Download::Idle;
|
||||||
|
state.error = Some(error.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request cancellation of the running download.
|
||||||
|
pub fn cancel_download() {
|
||||||
|
let state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
|
||||||
|
if let Download::Running { cancel, .. } = &state.download {
|
||||||
|
cancel.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the downloaded model and any partial download.
|
||||||
|
pub fn delete() {
|
||||||
|
super::parakeet::unload();
|
||||||
|
cancel_download();
|
||||||
|
|
||||||
|
if let Err(error) = fs::remove_dir_all(dir())
|
||||||
|
&& error.kind() != io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
log::warn!("failed to remove the speech model: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download(cancel: Arc<AtomicBool>) {
|
||||||
|
let result = download_inner(&cancel);
|
||||||
|
|
||||||
|
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
state.download = Download::Idle;
|
||||||
|
|
||||||
|
// A cancelled download must not report a spurious failure.
|
||||||
|
if !cancelled(&cancel)
|
||||||
|
&& let Err(error) = result
|
||||||
|
{
|
||||||
|
state.error = Some(format!("{error:#}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_inner(cancel: &AtomicBool) -> Result<()> {
|
||||||
|
let manifest = manifest();
|
||||||
|
let dir = dir();
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(20))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
|
for file in &manifest.files {
|
||||||
|
if cancelled(cancel) {
|
||||||
|
bail!("Cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
let staging = dir.join(format!("{}.part", file.name));
|
||||||
|
let result = download_file(&client, manifest, file, &staging, cancel);
|
||||||
|
|
||||||
|
if result.is_err() || cancelled(cancel) {
|
||||||
|
let _unused = fs::remove_file(&staging);
|
||||||
|
result?;
|
||||||
|
bail!("Cancelled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(dir.join("verified"), &manifest.revision)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_file(
|
||||||
|
client: &reqwest::blocking::Client,
|
||||||
|
manifest: &Manifest,
|
||||||
|
file: &Artifact,
|
||||||
|
staging: &Path,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> Result<()> {
|
||||||
|
use std::io::{Read as _, Write as _};
|
||||||
|
|
||||||
|
use sha2::Digest as _;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://huggingface.co/{}/resolve/{}/{}",
|
||||||
|
manifest.repository, manifest.revision, file.name
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut response = client.get(url).send()?.error_for_status()?;
|
||||||
|
let mut out = fs::File::create(staging)?;
|
||||||
|
let mut hash = sha2::Sha256::new();
|
||||||
|
let mut size = 0u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if cancelled(cancel) {
|
||||||
|
bail!("Cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chunk = [0u8; 64 * 1024];
|
||||||
|
let read = response.read(&mut chunk)?;
|
||||||
|
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
size += read as u64;
|
||||||
|
if size > file.size {
|
||||||
|
bail!("Unexpected model size");
|
||||||
|
}
|
||||||
|
|
||||||
|
hash.update(&chunk[..read]);
|
||||||
|
out.write_all(&chunk[..read])?;
|
||||||
|
|
||||||
|
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
if let Download::Running { downloaded, .. } = &mut state.download {
|
||||||
|
*downloaded += read as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != file.size || format!("{:x}", hash.finalize()) != file.sha256 {
|
||||||
|
bail!("Model checksum verification failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sync_all()?;
|
||||||
|
fs::rename(staging, dir().join(&file.name))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancelled(cancel: &AtomicBool) -> bool {
|
||||||
|
cancel.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Download {
|
||||||
|
Idle,
|
||||||
|
Running {
|
||||||
|
downloaded: u64,
|
||||||
|
total: u64,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
download: Download,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state() -> &'static Mutex<State> {
|
||||||
|
static STATE: Mutex<State> = Mutex::new(State {
|
||||||
|
download: Download::Idle,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
&STATE
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_pins_v3_artifacts_with_exact_sizes() {
|
||||||
|
let manifest = manifest();
|
||||||
|
|
||||||
|
assert_eq!(manifest.repository, "istupakov/parakeet-tdt-0.6b-v3-onnx");
|
||||||
|
assert_eq!(manifest.revision.len(), 40);
|
||||||
|
assert_eq!(manifest.files.len(), 3);
|
||||||
|
assert_eq!(total_size(), 670_479_942);
|
||||||
|
assert!(
|
||||||
|
manifest
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.all(|file| file.sha256.len() == 64 && !file.name.contains('/'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
|
use gpui::{App, Task};
|
||||||
|
use gpui_kit::component::speech::{
|
||||||
|
AudioFormat, RecognitionSession, SpeechError, SpeechRecognizer, SpeechSink,
|
||||||
|
};
|
||||||
|
use parakeet_rs::{ParakeetTDT, Transcriber as _};
|
||||||
|
|
||||||
|
const MODEL_RATE: u32 = 16_000;
|
||||||
|
const MAX_SECONDS: usize = 120;
|
||||||
|
|
||||||
|
/// The on-device recognizer.
|
||||||
|
pub struct ParakeetRecognizer;
|
||||||
|
|
||||||
|
impl SpeechRecognizer for ParakeetRecognizer {
|
||||||
|
fn audio_format(&self) -> AudioFormat {
|
||||||
|
AudioFormat::new(MODEL_RATE, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self, _cx: &App) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(
|
||||||
|
&self,
|
||||||
|
sink: SpeechSink,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
|
||||||
|
if super::model::installed() {
|
||||||
|
worker().send_or_log(Command::Warm(super::model::dir()));
|
||||||
|
} else if super::model::status().is_downloading() {
|
||||||
|
return Err(SpeechError::recognizer(anyhow!(
|
||||||
|
"The speech model is still downloading. Try again when it finishes."
|
||||||
|
)));
|
||||||
|
} else {
|
||||||
|
return Err(SpeechError::recognizer(anyhow!(
|
||||||
|
"No speech model is installed. Download it from Settings."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session buffers everything it is pushed,
|
||||||
|
// so audio is accepted right away.
|
||||||
|
sink.ready(cx);
|
||||||
|
|
||||||
|
Ok(Box::new(ParakeetSession {
|
||||||
|
sink,
|
||||||
|
samples: Vec::new(),
|
||||||
|
_result: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ParakeetSession {
|
||||||
|
sink: SpeechSink,
|
||||||
|
samples: Vec<f32>,
|
||||||
|
_result: Option<Task<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecognitionSession for ParakeetSession {
|
||||||
|
fn push_audio(&mut self, samples: &[i16], _cx: &mut App) {
|
||||||
|
if self.samples.len() < MAX_SECONDS * MODEL_RATE as usize {
|
||||||
|
self.samples
|
||||||
|
.extend(samples.iter().map(|sample| f32::from(*sample) / 32_768.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self, cx: &mut App) {
|
||||||
|
let samples = std::mem::take(&mut self.samples);
|
||||||
|
let sink = self.sink.clone();
|
||||||
|
|
||||||
|
// Speech too short to transcribe ends the session at once.
|
||||||
|
let silent = samples.iter().all(|sample| sample.abs() < 0.0001);
|
||||||
|
if samples.len() < MODEL_RATE as usize / 5 || silent {
|
||||||
|
sink.finish(cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (sender, receiver) = flume::unbounded();
|
||||||
|
worker().send_or_log(Command::Transcribe { samples, sender });
|
||||||
|
|
||||||
|
self._result = Some(cx.spawn(async move |cx| {
|
||||||
|
let result = receiver.recv_async().await;
|
||||||
|
|
||||||
|
cx.update(|cx| match result {
|
||||||
|
Ok(Ok(text)) if !text.is_empty() => {
|
||||||
|
sink.phrase(text, cx);
|
||||||
|
sink.finish(cx);
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) => sink.finish(cx),
|
||||||
|
Ok(Err(error)) => sink.error(SpeechError::recognizer(error), cx),
|
||||||
|
Err(_) => sink.error(
|
||||||
|
SpeechError::recognizer(anyhow!("The speech worker is unavailable")),
|
||||||
|
cx,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Command {
|
||||||
|
Warm(PathBuf),
|
||||||
|
Transcribe {
|
||||||
|
samples: Vec<f32>,
|
||||||
|
sender: flume::Sender<Result<String, anyhow::Error>>,
|
||||||
|
},
|
||||||
|
Unload,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A handle to the single inference worker.
|
||||||
|
fn worker() -> &'static flume::Sender<Command> {
|
||||||
|
static WORKER: OnceLock<flume::Sender<Command>> = OnceLock::new();
|
||||||
|
|
||||||
|
WORKER.get_or_init(|| {
|
||||||
|
let (sender, receiver) = flume::unbounded::<Command>();
|
||||||
|
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("speech-parakeet".into())
|
||||||
|
.spawn(move || run_worker(receiver));
|
||||||
|
|
||||||
|
if let Err(error) = spawned {
|
||||||
|
log::error!("failed to spawn the speech worker: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
sender
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_worker(receiver: flume::Receiver<Command>) {
|
||||||
|
let mut cached: Option<(PathBuf, ParakeetTDT)> = None;
|
||||||
|
|
||||||
|
while let Ok(command) = receiver.recv_timeout(Duration::from_secs(30)) {
|
||||||
|
match command {
|
||||||
|
Command::Unload => {
|
||||||
|
cached = None;
|
||||||
|
}
|
||||||
|
Command::Warm(dir) => {
|
||||||
|
let loaded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
load(&mut cached, &dir).map(|_| ())
|
||||||
|
}));
|
||||||
|
|
||||||
|
match flatten_panic(loaded) {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(error)) | Err(error) => {
|
||||||
|
log::warn!("failed to load the speech model: {error:#}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::Transcribe { samples, sender } => {
|
||||||
|
let transcribed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
transcribe(&mut cached, samples)
|
||||||
|
}));
|
||||||
|
|
||||||
|
let result = match flatten_panic(transcribed) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => {
|
||||||
|
cached = None;
|
||||||
|
Err(anyhow!("Dictation stopped unexpectedly. Try again."))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if sender.send(result).is_err() {
|
||||||
|
log::debug!("speech session was cancelled before its result arrived");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load<'a>(
|
||||||
|
cached: &'a mut Option<(PathBuf, ParakeetTDT)>,
|
||||||
|
dir: &PathBuf,
|
||||||
|
) -> Result<&'a mut ParakeetTDT> {
|
||||||
|
if cached.as_ref().is_none_or(|(path, _)| path != dir) {
|
||||||
|
let model = ParakeetTDT::from_pretrained(dir, None)
|
||||||
|
.map_err(|error| anyhow!("Could not load the speech model: {error}"))?;
|
||||||
|
*cached = Some((dir.clone(), model));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, model) = cached.as_mut().context("speech model was unloaded")?;
|
||||||
|
Ok(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transcribe(cached: &mut Option<(PathBuf, ParakeetTDT)>, samples: Vec<f32>) -> Result<String> {
|
||||||
|
let model = load(cached, &super::model::dir())?;
|
||||||
|
|
||||||
|
model
|
||||||
|
.transcribe_samples(samples, MODEL_RATE, 1, None)
|
||||||
|
.map(|result| result.text)
|
||||||
|
.map_err(|error| anyhow!("Could not transcribe this recording: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
trait SendOrLog {
|
||||||
|
fn send_or_log(&self, command: Command);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SendOrLog for flume::Sender<Command> {
|
||||||
|
fn send_or_log(&self, command: Command) {
|
||||||
|
if self.send(command).is_err() {
|
||||||
|
log::debug!("speech worker is gone; command dropped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retire cached weights, e.g. after the model was deleted.
|
||||||
|
pub(crate) fn unload() {
|
||||||
|
worker().send_or_log(Command::Unload);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> {
|
||||||
|
result.map_err(|_| anyhow!("The speech worker panicked"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use gpui::App;
|
||||||
|
use gpui_kit::Subscription;
|
||||||
|
use gpui_kit::component::speech::{
|
||||||
|
AudioFormat, AudioInput, AudioSink, RecognitionSession, SpeechError, SpeechRecognizer,
|
||||||
|
SpeechSink,
|
||||||
|
};
|
||||||
|
use js_sys::{Array, Function, JsString, Reflect};
|
||||||
|
use wasm_bindgen::closure::Closure;
|
||||||
|
use wasm_bindgen::{JsCast as _, JsValue};
|
||||||
|
use web_sys::window;
|
||||||
|
|
||||||
|
/// The recognizer backed by `SpeechRecognition` (or its `webkit` prefix).
|
||||||
|
pub struct WebSpeechRecognizer;
|
||||||
|
|
||||||
|
impl SpeechRecognizer for WebSpeechRecognizer {
|
||||||
|
fn audio_format(&self) -> AudioFormat {
|
||||||
|
AudioFormat::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self, _cx: &App) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(
|
||||||
|
&self,
|
||||||
|
sink: SpeechSink,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
|
||||||
|
let recognition = Recognition::open(sink, cx).map_err(SpeechError::recognizer)?;
|
||||||
|
|
||||||
|
Ok(Box::new(recognition))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SelfCapturedInput;
|
||||||
|
|
||||||
|
impl AudioInput for SelfCapturedInput {
|
||||||
|
fn start(
|
||||||
|
&self,
|
||||||
|
_format: AudioFormat,
|
||||||
|
_sink: AudioSink,
|
||||||
|
_cx: &mut App,
|
||||||
|
) -> Result<Subscription, SpeechError> {
|
||||||
|
Ok(Subscription::new(|| {}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Event {
|
||||||
|
Hypothesis(String),
|
||||||
|
Phrase(String),
|
||||||
|
Error(String),
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Recognition {
|
||||||
|
recognition: JsValue,
|
||||||
|
on_result: Option<Closure<dyn FnMut(JsValue)>>,
|
||||||
|
on_error: Option<Closure<dyn FnMut(JsValue)>>,
|
||||||
|
on_end: Option<Closure<dyn FnMut(JsValue)>>,
|
||||||
|
_task: gpui::Task<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Recognition {
|
||||||
|
fn open(sink: SpeechSink, cx: &mut App) -> anyhow::Result<Self> {
|
||||||
|
let window = window().ok_or_else(|| anyhow!("No browser window"))?;
|
||||||
|
|
||||||
|
let constructor = Reflect::get(&window, &JsString::from("SpeechRecognition"))
|
||||||
|
.ok()
|
||||||
|
.filter(|value| value.is_function())
|
||||||
|
.or_else(|| {
|
||||||
|
Reflect::get(&window, &JsString::from("webkitSpeechRecognition"))
|
||||||
|
.ok()
|
||||||
|
.filter(|value| value.is_function())
|
||||||
|
})
|
||||||
|
.ok_or_else(|| anyhow!("This browser does not support speech recognition"))?;
|
||||||
|
|
||||||
|
let recognition =
|
||||||
|
Reflect::construct(constructor.unchecked_ref::<Function>(), &Array::new())
|
||||||
|
.map_err(|error| js_error("constructing the recognizer", error))?;
|
||||||
|
|
||||||
|
let (sender, receiver) = flume::unbounded::<Event>();
|
||||||
|
let error_sender = sender.clone();
|
||||||
|
let end_sender = sender.clone();
|
||||||
|
|
||||||
|
let finalized = RefCell::new(0usize);
|
||||||
|
let on_result = Closure::wrap(Box::new(move |event: JsValue| {
|
||||||
|
let read = |value: &JsValue, property: &str| -> Option<JsValue> {
|
||||||
|
Reflect::get(value, &JsString::from(property)).ok()
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(results) = read(&event, "results") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let length = read(&results, "length")
|
||||||
|
.and_then(|length| length.as_f64())
|
||||||
|
.unwrap_or(0.0) as usize;
|
||||||
|
|
||||||
|
let mut finalized = finalized.borrow_mut();
|
||||||
|
let mut interim = String::new();
|
||||||
|
|
||||||
|
for index in *finalized..length {
|
||||||
|
let Some(result) = read(&results, &index.to_string()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(alternative) = read(&result, "0") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(text) = read(&alternative, "transcript").and_then(|text| text.as_string())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_final = read(&result, "isFinal")
|
||||||
|
.and_then(|final_value| final_value.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if is_final {
|
||||||
|
*finalized += 1;
|
||||||
|
if sender.send(Event::Phrase(text)).is_err() {
|
||||||
|
log::debug!("speech session was cancelled mid-utterance");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
interim.push_str(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !interim.is_empty() && sender.send(Event::Hypothesis(interim)).is_err() {
|
||||||
|
log::debug!("speech session was cancelled mid-utterance");
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(JsValue)>);
|
||||||
|
|
||||||
|
let on_error = Closure::wrap(Box::new(move |event: JsValue| {
|
||||||
|
let message = Reflect::get(&event, &JsString::from("error"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|error| error.as_string())
|
||||||
|
.unwrap_or_else(|| "unknown speech error".into());
|
||||||
|
|
||||||
|
if error_sender.send(Event::Error(message)).is_err() {
|
||||||
|
log::debug!("speech session was cancelled before its error arrived");
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(JsValue)>);
|
||||||
|
|
||||||
|
let on_end = Closure::wrap(Box::new(move |_event: JsValue| {
|
||||||
|
if end_sender.send(Event::End).is_err() {
|
||||||
|
log::debug!("speech session was cancelled before its end arrived");
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(JsValue)>);
|
||||||
|
|
||||||
|
set_callback(&recognition, "onresult", on_result.as_ref().unchecked_ref())?;
|
||||||
|
set_callback(&recognition, "onerror", on_error.as_ref().unchecked_ref())?;
|
||||||
|
set_callback(&recognition, "onend", on_end.as_ref().unchecked_ref())?;
|
||||||
|
set_bool(&recognition, "continuous", true)?;
|
||||||
|
set_bool(&recognition, "interimResults", true)?;
|
||||||
|
|
||||||
|
call_method(&recognition, "start")?;
|
||||||
|
|
||||||
|
// The browser consumes audio as soon as it has permission
|
||||||
|
sink.ready(cx);
|
||||||
|
|
||||||
|
let task = cx.spawn(async move |cx| {
|
||||||
|
while let Ok(event) = receiver.recv_async().await {
|
||||||
|
let done = matches!(event, Event::End);
|
||||||
|
|
||||||
|
// The speech state ignores sink calls once its session ended,
|
||||||
|
// so a late event after a cancel is harmless.
|
||||||
|
cx.update(|cx| match event {
|
||||||
|
Event::Hypothesis(text) => sink.hypothesis(text, cx),
|
||||||
|
Event::Phrase(text) => sink.phrase(text, cx),
|
||||||
|
Event::Error(message) => {
|
||||||
|
sink.error(SpeechError::recognizer(anyhow!("{message}")), cx)
|
||||||
|
}
|
||||||
|
Event::End => sink.finish(cx),
|
||||||
|
});
|
||||||
|
|
||||||
|
if done {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
recognition,
|
||||||
|
on_result: Some(on_result),
|
||||||
|
on_error: Some(on_error),
|
||||||
|
on_end: Some(on_end),
|
||||||
|
_task: task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self) {
|
||||||
|
self.on_result = None;
|
||||||
|
self.on_error = None;
|
||||||
|
self.on_end = None;
|
||||||
|
|
||||||
|
let _ignored = call_method(&self.recognition, "abort");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Recognition {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecognitionSession for Recognition {
|
||||||
|
fn push_audio(&mut self, _samples: &[i16], _cx: &mut App) {
|
||||||
|
// The browser recognizer captures the microphone itself.
|
||||||
|
// the state still pushes audio through its input, which is a no-op here.
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self, _cx: &mut App) {
|
||||||
|
// The end handler reports completion, so no result is fabricated here.
|
||||||
|
let _ignored = call_method(&self.recognition, "stop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_callback(target: &JsValue, property: &str, callback: &Function) -> anyhow::Result<()> {
|
||||||
|
Reflect::set(target, &JsString::from(property), callback)
|
||||||
|
.map_err(|error| js_error(property, error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_bool(target: &JsValue, property: &str, value: bool) -> anyhow::Result<()> {
|
||||||
|
Reflect::set(target, &JsString::from(property), &JsValue::from(value))
|
||||||
|
.map_err(|error| js_error(property, error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_method(target: &JsValue, method: &str) -> anyhow::Result<()> {
|
||||||
|
let function = Reflect::get(target, &JsString::from(method))
|
||||||
|
.map_err(|error| js_error(method, error))?
|
||||||
|
.unchecked_into::<Function>();
|
||||||
|
|
||||||
|
function
|
||||||
|
.call0(target)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|error| js_error(method, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn js_error(what: &str, error: JsValue) -> anyhow::Error {
|
||||||
|
let description = error
|
||||||
|
.as_string()
|
||||||
|
.or_else(|| {
|
||||||
|
Reflect::get(&error, &JsString::from("message"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|message| message.as_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "unknown error".into());
|
||||||
|
|
||||||
|
anyhow!("{what} failed: {description}")
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ futures.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
webbrowser.workspace = true
|
webbrowser.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
|
||||||
mime_guess = "2.0.4"
|
mime_guess = "2.0.4"
|
||||||
|
|
||||||
@@ -28,7 +29,24 @@ sha2.workspace = true
|
|||||||
data-encoding.workspace = true
|
data-encoding.workspace = true
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
js-sys = "0.3"
|
||||||
|
nostr-browser-signer.workspace = true
|
||||||
nostr-memory.workspace = true
|
nostr-memory.workspace = true
|
||||||
|
nostr-sqlite.workspace = true
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
web-sys = { version = "0.3", features = [
|
||||||
|
"Blob",
|
||||||
|
"BlobPropertyBag",
|
||||||
|
"File",
|
||||||
|
"FileList",
|
||||||
|
"HtmlAnchorElement",
|
||||||
|
"HtmlInputElement",
|
||||||
|
"Url",
|
||||||
|
"Window",
|
||||||
|
] }
|
||||||
|
sqlite-wasm-rs = "0.5"
|
||||||
|
sqlite-wasm-vfs = "0.2"
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use gpui::AsyncApp;
|
use gpui::AsyncApp;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use mime_guess::from_path;
|
|
||||||
use nostr_blossom::prelude::*;
|
use nostr_blossom::prelude::*;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use crate::file::sha256_hex;
|
|
||||||
|
|
||||||
/// Upload a blob to a blossom server and return its URL
|
/// Upload a blob to a blossom server and return its URL
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub(crate) async fn upload_blob(
|
pub(crate) async fn upload_blob(
|
||||||
@@ -41,16 +34,26 @@ pub(crate) async fn upload_blob(
|
|||||||
.map_err(|e| anyhow!("Upload error: {e}"))?
|
.map_err(|e| anyhow!("Upload error: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
/// Upload a blob to a blossom server and return its URL.
|
||||||
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
|
||||||
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
|
||||||
let data = smol::fs::read(&path).await?;
|
|
||||||
let sha256 = sha256_hex(&data);
|
|
||||||
|
|
||||||
upload_blob(&server, data, &content_type, &sha256, cx).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
pub(crate) async fn upload_blob(
|
||||||
Err(anyhow!("File upload not supported on web"))
|
server: &Url,
|
||||||
|
data: Vec<u8>,
|
||||||
|
content_type: &str,
|
||||||
|
sha256: &str,
|
||||||
|
_cx: &AsyncApp,
|
||||||
|
) -> Result<Url, Error> {
|
||||||
|
let client = BlossomClient::new(server.clone());
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let base = server.clone();
|
||||||
|
let hash = sha256.to_string();
|
||||||
|
|
||||||
|
match client
|
||||||
|
.upload_blob(data, Some(content_type.to_string()), None, Some(&keys))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(blob) => Ok(blob.url),
|
||||||
|
Err(e) if e.to_string().contains("201 Created") => Ok(base.join(&hash)?),
|
||||||
|
Err(e) => Err(anyhow!(e.to_string())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use common::web_store;
|
||||||
|
use gpui::{App, AppContext as _, Task};
|
||||||
|
|
||||||
|
use crate::USER_KEYRING;
|
||||||
|
|
||||||
|
/// The storage key for the NIP-4e app keys
|
||||||
|
const APP_KEYS_KEY: &str = "coop/nip4e/app-keys";
|
||||||
|
|
||||||
|
/// Read the stored credential as `(username, secret)`
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(crate) fn read(cx: &App) -> Task<Result<Option<(String, Vec<u8>)>>> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
return cx.read_credentials(USER_KEYRING);
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = cx;
|
||||||
|
Task::ready(
|
||||||
|
web_store::get(USER_KEYRING)
|
||||||
|
.map(|stored| stored.map(|content| (String::new(), content.into_bytes()))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the stored credential
|
||||||
|
pub(crate) fn write(cx: &App, username: &str, secret: &[u8]) -> Task<Result<()>> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
return cx.write_credentials(USER_KEYRING, username, secret);
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = (cx, username);
|
||||||
|
let content = match std::str::from_utf8(secret) {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(e) => return Task::ready(Err(anyhow::anyhow!("credential is not UTF-8: {e}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
Task::ready(web_store::set(USER_KEYRING, content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the stored credential
|
||||||
|
pub(crate) fn delete(cx: &App) -> Task<Result<()>> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
return cx.delete_credentials(USER_KEYRING);
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = cx;
|
||||||
|
Task::ready(web_store::remove(USER_KEYRING))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read named bytes from the platform keyring,
|
||||||
|
/// falling back to browser storage on web where no keyring exists
|
||||||
|
fn read_named(cx: &App, key: &str) -> Task<Result<Option<Vec<u8>>>> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let key = key.to_owned();
|
||||||
|
let read = cx.read_credentials(&key);
|
||||||
|
cx.background_spawn(async move { Ok(read.await?.map(|(_, secret)| secret)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = cx;
|
||||||
|
let stored = web_store::get(key).map(|content| content.map(|s| s.into_bytes()));
|
||||||
|
Task::ready(stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write named bytes to the platform keyring,
|
||||||
|
/// falling back to browser storage on web where no keyring exists
|
||||||
|
fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task<Result<()>> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let key = key.to_owned();
|
||||||
|
let secret = secret.to_vec();
|
||||||
|
let write = cx.write_credentials(&key, "", &secret);
|
||||||
|
cx.background_spawn(write)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = cx;
|
||||||
|
let content = match std::str::from_utf8(secret) {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(e) => return Task::ready(Err(anyhow::anyhow!("secret is not UTF-8: {e}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
Task::ready(web_store::set(key, content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the NIP-4e app keys from storage, as raw secret bytes
|
||||||
|
pub fn read_app_keys(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
|
||||||
|
read_named(cx, APP_KEYS_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the NIP-4e app keys, as raw secret bytes
|
||||||
|
pub fn write_app_keys(cx: &App, secret: &[u8]) -> Task<Result<()>> {
|
||||||
|
write_named(cx, APP_KEYS_KEY, secret)
|
||||||
|
}
|
||||||
@@ -7,8 +7,10 @@ use aes_gcm::{Aes256Gcm, AesGcm, Nonce};
|
|||||||
use anyhow::{Error, anyhow, bail};
|
use anyhow::{Error, anyhow, bail};
|
||||||
use data_encoding::HEXLOWER;
|
use data_encoding::HEXLOWER;
|
||||||
use futures::AsyncReadExt;
|
use futures::AsyncReadExt;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui::PathPromptOptions;
|
||||||
use gpui::http_client::AsyncBody;
|
use gpui::http_client::AsyncBody;
|
||||||
use gpui::{AsyncApp, SharedString};
|
use gpui::{AppContext as _, AsyncApp, SharedString, SharedUri, Task};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use mime_guess::from_path;
|
use mime_guess::from_path;
|
||||||
use nostr::nips::nip94::Sha256Hash;
|
use nostr::nips::nip94::Sha256Hash;
|
||||||
@@ -115,6 +117,56 @@ impl FileAttachment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A file the user picked, read into memory.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PickedFile {
|
||||||
|
pub name: String,
|
||||||
|
pub mime: String,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
pub local: LocalFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A handle to a file stored on the user's device
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum LocalFile {
|
||||||
|
/// A path on disk (desktop)
|
||||||
|
Path(PathBuf),
|
||||||
|
/// An in-memory blob URL (web)
|
||||||
|
Url(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalFile {
|
||||||
|
/// The source an `img` element can render
|
||||||
|
pub fn image_source(&self) -> gpui::ImageSource {
|
||||||
|
match self {
|
||||||
|
LocalFile::Path(path) => path.clone().into(),
|
||||||
|
LocalFile::Url(url) => SharedUri::from(url.clone()).into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL the platform opener understands
|
||||||
|
pub fn open_url(&self) -> String {
|
||||||
|
match self {
|
||||||
|
LocalFile::Path(path) => format!("file://{}", path.display()),
|
||||||
|
LocalFile::Url(url) => url.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the file with the platform handler
|
||||||
|
pub fn open(&self, cx: &gpui::App) -> Result<(), Error> {
|
||||||
|
match self {
|
||||||
|
LocalFile::Path(_) => {
|
||||||
|
cx.open_url(&self.open_url());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
LocalFile::Url(url) => crate::web_file::open_url(url),
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
LocalFile::Url(_) => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
pub(crate) fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
||||||
let key = Aes256Gcm::generate_key(OsRng);
|
let key = Aes256Gcm::generate_key(OsRng);
|
||||||
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
|
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
|
||||||
@@ -165,21 +217,63 @@ pub(crate) fn sha256_hex(data: &[u8]) -> String {
|
|||||||
Sha256Hash::from_byte_array(hash).to_hex()
|
Sha256Hash::from_byte_array(hash).to_hex()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the user for a single file and read it into memory
|
||||||
|
pub fn pick_file(cx: &gpui::App) -> Task<Result<Option<PickedFile>, Error>> {
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub async fn upload_encrypted(
|
{
|
||||||
server: Url,
|
let dialog = cx.prompt_for_paths(PathPromptOptions {
|
||||||
path: PathBuf,
|
files: true,
|
||||||
cx: &AsyncApp,
|
directories: false,
|
||||||
) -> Result<FileAttachment, Error> {
|
multiple: false,
|
||||||
|
prompt: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let Some(path) = dialog.await?? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let path = path.into_iter().next().ok_or_else(|| anyhow!("No file"))?;
|
||||||
|
|
||||||
let mime = from_path(&path).first_or_octet_stream().to_string();
|
let mime = from_path(&path).first_or_octet_stream().to_string();
|
||||||
let name = path
|
let name = path
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|name| name.to_string_lossy().into_owned());
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| path.display().to_string());
|
||||||
let data = smol::fs::read(&path).await?;
|
let data = smol::fs::read(&path).await?;
|
||||||
|
|
||||||
let encrypted = encrypt(&data)?;
|
Ok(Some(PickedFile {
|
||||||
|
name,
|
||||||
|
mime,
|
||||||
|
data,
|
||||||
|
local: LocalFile::Path(path),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = cx;
|
||||||
|
cx.background_spawn(async move { crate::web_file::pick_file().await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload a file unencrypted and return its URL
|
||||||
|
pub async fn upload_file(server: Url, file: PickedFile, cx: &AsyncApp) -> Result<Url, Error> {
|
||||||
|
let PickedFile { mime, data, .. } = file;
|
||||||
|
let sha256 = sha256_hex(&data);
|
||||||
|
|
||||||
|
crate::blossom::upload_blob(&server, data, &mime, &sha256, cx).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload a file encrypted as an opaque blob, returning the attachment metadata
|
||||||
|
pub async fn upload_encrypted_file(
|
||||||
|
server: Url,
|
||||||
|
file: PickedFile,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<FileAttachment, Error> {
|
||||||
|
let encrypted = encrypt(&file.data)?;
|
||||||
let sha256 = sha256_hex(&encrypted.data);
|
let sha256 = sha256_hex(&encrypted.data);
|
||||||
let original_sha256 = sha256_hex(&data);
|
let original_sha256 = sha256_hex(&file.data);
|
||||||
let size = encrypted.data.len() as u64;
|
let size = encrypted.data.len() as u64;
|
||||||
let base_url = server.to_string();
|
let base_url = server.to_string();
|
||||||
|
|
||||||
@@ -207,26 +301,17 @@ pub async fn upload_encrypted(
|
|||||||
|
|
||||||
Ok(FileAttachment {
|
Ok(FileAttachment {
|
||||||
url,
|
url,
|
||||||
mime,
|
mime: file.mime,
|
||||||
key: encrypted.key,
|
key: encrypted.key,
|
||||||
nonce: encrypted.nonce,
|
nonce: encrypted.nonce,
|
||||||
sha256: Some(sha256),
|
sha256: Some(sha256),
|
||||||
original_sha256: Some(original_sha256),
|
original_sha256: Some(original_sha256),
|
||||||
size: Some(size),
|
size: Some(size),
|
||||||
dim: None,
|
dim: None,
|
||||||
name,
|
name: Some(file.name),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub async fn upload_encrypted(
|
|
||||||
_server: Url,
|
|
||||||
_path: PathBuf,
|
|
||||||
_cx: &AsyncApp,
|
|
||||||
) -> Result<FileAttachment, Error> {
|
|
||||||
Err(anyhow!("File upload not supported on web"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn download_and_decrypt(
|
pub(crate) async fn download_and_decrypt(
|
||||||
url: &Url,
|
url: &Url,
|
||||||
key: &str,
|
key: &str,
|
||||||
@@ -261,33 +346,49 @@ pub(crate) async fn download_and_decrypt(
|
|||||||
decrypt(&data, key, nonce)
|
decrypt(&data, key, nonce)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download and decrypt a file attachment into a temporary file.
|
/// A downloaded and decrypted attachment on the user's device
|
||||||
///
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
/// The same attachment always maps to the same path, so callers can render the
|
pub struct DownloadedFile {
|
||||||
/// result directly (e.g. with `img`) without downloading it more than once.
|
pub local: LocalFile,
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
pub mime: String,
|
||||||
pub async fn download_and_decrypt_to_file(
|
pub name: String,
|
||||||
file: &FileAttachment,
|
/// The decrypted bytes, kept so the web can offer them as a download
|
||||||
cx: &AsyncApp,
|
#[cfg(target_arch = "wasm32")]
|
||||||
) -> Result<PathBuf, Error> {
|
pub(crate) data: Vec<u8>,
|
||||||
let name = file
|
|
||||||
.sha256
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
|
|
||||||
|
|
||||||
let extension = mime_guess::get_mime_extensions_str(&file.mime)
|
|
||||||
.and_then(|extensions| extensions.first())
|
|
||||||
.copied()
|
|
||||||
.unwrap_or("bin");
|
|
||||||
|
|
||||||
let path = std::env::temp_dir()
|
|
||||||
.join("coop-files")
|
|
||||||
.join(format!("{name}.{extension}"));
|
|
||||||
|
|
||||||
if smol::fs::metadata(&path).await.is_ok() {
|
|
||||||
return Ok(path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DownloadedFile {
|
||||||
|
pub fn open(&self, cx: &gpui::App) -> Result<(), Error> {
|
||||||
|
match &self.local {
|
||||||
|
LocalFile::Path(path) => {
|
||||||
|
cx.open_with_system(path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
LocalFile::Url(url) => {
|
||||||
|
if self.mime.starts_with("image/") {
|
||||||
|
crate::web_file::open_url(url)
|
||||||
|
} else {
|
||||||
|
crate::web_file::download(&self.name, &self.mime, &self.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
LocalFile::Url(_) => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the file to the user's downloads (web)
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn save(&self) -> Result<(), Error> {
|
||||||
|
crate::web_file::download(&self.name, &self.mime, &self.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download and decrypt a file attachment to the user's device
|
||||||
|
pub async fn download_and_decrypt_file(
|
||||||
|
file: &FileAttachment,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<DownloadedFile, Error> {
|
||||||
let data = download_and_decrypt(
|
let data = download_and_decrypt(
|
||||||
&file.url,
|
&file.url,
|
||||||
&file.key,
|
&file.key,
|
||||||
@@ -297,6 +398,37 @@ pub async fn download_and_decrypt_to_file(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let name = file
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| fallback_name(&file.url));
|
||||||
|
|
||||||
|
let local = store_file(&name, &file.mime, &data).await?;
|
||||||
|
|
||||||
|
Ok(DownloadedFile {
|
||||||
|
local,
|
||||||
|
mime: file.mime.clone(),
|
||||||
|
name,
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist decrypted bytes somewhere the platform can render or open them
|
||||||
|
async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let digest = sha256_hex(name.as_bytes());
|
||||||
|
let extension = mime_guess::get_mime_extensions_str(mime)
|
||||||
|
.and_then(|extensions| extensions.first())
|
||||||
|
.copied()
|
||||||
|
.unwrap_or("bin");
|
||||||
|
|
||||||
|
let path = std::env::temp_dir()
|
||||||
|
.join("coop-files")
|
||||||
|
.join(format!("{digest}.{extension}"));
|
||||||
|
|
||||||
|
if smol::fs::metadata(&path).await.is_err() {
|
||||||
let Some(parent) = path.parent() else {
|
let Some(parent) = path.parent() else {
|
||||||
bail!("Invalid file path");
|
bail!("Invalid file path");
|
||||||
};
|
};
|
||||||
@@ -306,16 +438,26 @@ pub async fn download_and_decrypt_to_file(
|
|||||||
let partial = path.with_extension("download");
|
let partial = path.with_extension("download");
|
||||||
smol::fs::write(&partial, data).await?;
|
smol::fs::write(&partial, data).await?;
|
||||||
smol::fs::rename(&partial, &path).await?;
|
smol::fs::rename(&partial, &path).await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(path)
|
Ok(LocalFile::Path(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub async fn download_and_decrypt_to_file(
|
{
|
||||||
_file: &FileAttachment,
|
let _ = name;
|
||||||
_cx: &AsyncApp,
|
let url = crate::web_file::blob_url(mime, data)?;
|
||||||
) -> Result<PathBuf, Error> {
|
Ok(LocalFile::Url(url))
|
||||||
Err(anyhow!("File download not supported on web"))
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file name derived from the attachment URL's last path segment
|
||||||
|
fn fallback_name(url: &Url) -> String {
|
||||||
|
url.path()
|
||||||
|
.rsplit('/')
|
||||||
|
.find(|segment| !segment.is_empty())
|
||||||
|
.unwrap_or("attachment")
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||||
|
|||||||
@@ -4,26 +4,33 @@ use std::sync::Arc;
|
|||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use browser_signer_proxy::prelude::*;
|
use browser_signer_proxy::prelude::*;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use nostr_browser_signer::BrowserSigner;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use nostr_gossip_memory::prelude::*;
|
use nostr_gossip_memory::prelude::*;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use nostr_lmdb::prelude::*;
|
use nostr_lmdb::prelude::*;
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use nostr_memory::prelude::*;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
mod blossom;
|
mod blossom;
|
||||||
mod constants;
|
mod constants;
|
||||||
|
pub mod credentials;
|
||||||
mod file;
|
mod file;
|
||||||
mod nip05;
|
mod nip05;
|
||||||
mod nip4e;
|
mod nip4e;
|
||||||
mod signer;
|
mod signer;
|
||||||
|
|
||||||
pub use blossom::*;
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod web_database;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod web_file;
|
||||||
|
|
||||||
pub use constants::*;
|
pub use constants::*;
|
||||||
pub use file::*;
|
pub use file::*;
|
||||||
pub use nip4e::*;
|
pub use nip4e::*;
|
||||||
@@ -107,7 +114,6 @@ impl NostrRegistry {
|
|||||||
let signer = UniversalSigner::new(Keys::generate());
|
let signer = UniversalSigner::new(Keys::generate());
|
||||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||||
|
|
||||||
// Construct the nostr lmdb instance
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let database = cx.foreground_executor().block_on(async move {
|
let database = cx.foreground_executor().block_on(async move {
|
||||||
NostrLmdb::open(config_dir().join("nostr"))
|
NostrLmdb::open(config_dir().join("nostr"))
|
||||||
@@ -116,7 +122,7 @@ impl NostrRegistry {
|
|||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
let database = MemoryDatabase::unbounded();
|
let database = web_database::take();
|
||||||
|
|
||||||
// Construct the nostr client
|
// Construct the nostr client
|
||||||
let client = ClientBuilder::default()
|
let client = ClientBuilder::default()
|
||||||
@@ -137,9 +143,7 @@ impl NostrRegistry {
|
|||||||
let _ = entity.update(cx, |this, cx| {
|
let _ = entity.update(cx, |this, cx| {
|
||||||
this.connect_bootstrap_relays(cx);
|
this.connect_bootstrap_relays(cx);
|
||||||
|
|
||||||
if cfg!(target_arch = "wasm32") {
|
if let Some(secret) = cli_key {
|
||||||
cx.emit(StateEvent::NoSigner);
|
|
||||||
} else if let Some(secret) = cli_key {
|
|
||||||
// Use CLI-provided key -- same path as restore_session
|
// Use CLI-provided key -- same path as restore_session
|
||||||
let keys = Keys::new(secret);
|
let keys = Keys::new(secret);
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
@@ -241,7 +245,7 @@ impl NostrRegistry {
|
|||||||
|
|
||||||
/// Restore the signer from the stored credential
|
/// Restore the signer from the stored credential
|
||||||
fn restore_session(&mut self, cx: &mut Context<Self>) {
|
fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
let user_keyring = credentials::read(cx);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let result = async {
|
let result = async {
|
||||||
@@ -278,6 +282,11 @@ impl NostrRegistry {
|
|||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.connect_proxy(cx);
|
this.connect_proxy(cx);
|
||||||
})?;
|
})?;
|
||||||
|
} else if content == "extension" {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.connect_extension(cx);
|
||||||
|
})?;
|
||||||
} else {
|
} else {
|
||||||
this.update(cx, |_, cx| {
|
this.update(cx, |_, cx| {
|
||||||
cx.emit(StateEvent::NoSigner);
|
cx.emit(StateEvent::NoSigner);
|
||||||
@@ -313,7 +322,7 @@ impl NostrRegistry {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Task<Result<PublicKey, Error>> {
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
let password = password.to_owned();
|
let password = password.to_owned();
|
||||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
let user_keyring = credentials::read(cx);
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let content = user_keyring
|
let content = user_keyring
|
||||||
@@ -372,9 +381,8 @@ impl NostrRegistry {
|
|||||||
let (keys, ncryptsec) = job.await?;
|
let (keys, ncryptsec) = job.await?;
|
||||||
let public_key = keys.public_key();
|
let public_key = keys.public_key();
|
||||||
|
|
||||||
let write = cx.update(|cx| {
|
let write =
|
||||||
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
|
cx.update(|cx| credentials::write(cx, &public_key.to_hex(), ncryptsec.as_bytes()));
|
||||||
});
|
|
||||||
write.await?;
|
write.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -411,9 +419,8 @@ impl NostrRegistry {
|
|||||||
let keys = decrypt_task.await?;
|
let keys = decrypt_task.await?;
|
||||||
let public_key = keys.public_key();
|
let public_key = keys.public_key();
|
||||||
|
|
||||||
let write = cx.update(|cx| {
|
let write =
|
||||||
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes())
|
cx.update(|cx| credentials::write(cx, &public_key.to_hex(), stored.as_bytes()));
|
||||||
});
|
|
||||||
write.await?;
|
write.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -439,7 +446,7 @@ impl NostrRegistry {
|
|||||||
|
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
let credential = with_master_key(&uri_string, &keys);
|
let credential = with_master_key(&uri_string, &keys);
|
||||||
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
let write = credentials::write(cx, "bunker", credential.as_bytes());
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let signer = NostrConnect::builder(connect_uri, keys)
|
let signer = NostrConnect::builder(connect_uri, keys)
|
||||||
@@ -463,12 +470,50 @@ impl NostrRegistry {
|
|||||||
self.current_user = None;
|
self.current_user = None;
|
||||||
self.passphrase_required = false;
|
self.passphrase_required = false;
|
||||||
|
|
||||||
cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx);
|
credentials::delete(cx).detach_and_log_err(cx);
|
||||||
cx.emit(StateEvent::SignerChanged);
|
cx.emit(StateEvent::SignerChanged);
|
||||||
cx.emit(StateEvent::NoSigner);
|
cx.emit(StateEvent::NoSigner);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Connect the browser extension signer (NIP-07).
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn connect_extension(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let signer = match BrowserSigner::new() {
|
||||||
|
Ok(signer) => signer,
|
||||||
|
Err(e) => {
|
||||||
|
cx.emit(StateEvent::error(format!("No Nostr extension found: {e}.")));
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
match signer.get_public_key_async().await {
|
||||||
|
Ok(public_key) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
credentials::write(cx, "extension", b"extension").detach_and_log_err(cx);
|
||||||
|
this.signer.swap_inner(signer);
|
||||||
|
this.current_user = Some(public_key);
|
||||||
|
this.passphrase_required = false;
|
||||||
|
|
||||||
|
cx.emit(StateEvent::SignerChanged);
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |_, cx| {
|
||||||
|
credentials::delete(cx).detach_and_log_err(cx);
|
||||||
|
cx.emit(StateEvent::error(e.to_string()));
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/// Start the browser proxy
|
/// Start the browser proxy
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
||||||
|
|||||||
@@ -188,7 +188,13 @@ impl AuthUrlHandler for CoopAuthUrlHandler {
|
|||||||
auth_url: Url,
|
auth_url: Url,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?;
|
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
crate::web_file::open_url(auth_url.as_str())
|
||||||
|
.map_err(nostr_connect::error::Error::other)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use nostr_memory::prelude::*;
|
||||||
|
use nostr_sqlite::prelude::NostrSqlite;
|
||||||
|
use sqlite_wasm_rs::WasmOsCallback;
|
||||||
|
use sqlite_wasm_vfs::relaxed_idb;
|
||||||
|
|
||||||
|
const DATABASE_PATH: &str = "coop.sqlite3";
|
||||||
|
const VFS_NAME: &str = "coop-relaxed-idb";
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static DATABASE: RefCell<Option<NostrSqlite>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the IndexedDB storage backend and open the database.
|
||||||
|
pub async fn open() {
|
||||||
|
match open_persistent().await {
|
||||||
|
Some(database) => DATABASE.with(|slot| *slot.borrow_mut() = Some(database)),
|
||||||
|
None => log::error!("Falling back to the in-memory nostr database"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand the opened database to the client constructor
|
||||||
|
pub(crate) fn take() -> Arc<dyn NostrDatabase> {
|
||||||
|
let database = DATABASE.with(|slot| slot.borrow_mut().take());
|
||||||
|
|
||||||
|
match database {
|
||||||
|
Some(database) => Arc::new(database),
|
||||||
|
None => {
|
||||||
|
log::error!("Persistent web database was not opened, falling back to memory");
|
||||||
|
Arc::new(MemoryDatabase::unbounded())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the database on IndexedDB storage, or `None` when unavailable
|
||||||
|
async fn open_persistent() -> Option<NostrSqlite> {
|
||||||
|
let options = relaxed_idb::RelaxedIdbCfgBuilder::new()
|
||||||
|
.vfs_name(VFS_NAME)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if let Err(e) = relaxed_idb::install::<WasmOsCallback>(&options, true).await {
|
||||||
|
log::warn!("IndexedDB storage unavailable: {e:?}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match NostrSqlite::builder()
|
||||||
|
.persistent_with_vfs(DATABASE_PATH, VFS_NAME)
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(database) => {
|
||||||
|
log::info!("Nostr database opened on IndexedDB storage");
|
||||||
|
Some(database)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to open the nostr database on IndexedDB: {e:?}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//! Browser file interoperability: a persistent hidden `<input type="file">`
|
||||||
|
//! picker, blob URLs for rendering, and anchor-based downloads.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use anyhow::{Error, anyhow};
|
||||||
|
use flume::Sender;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen::closure::Closure;
|
||||||
|
use web_sys::{File, HtmlAnchorElement, HtmlInputElement, Url as DomUrl, window as js_window};
|
||||||
|
|
||||||
|
use crate::file::PickedFile;
|
||||||
|
|
||||||
|
type PickResult = Result<Option<PickedFile>, Error>;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// The picker input lives for the whole page so its change closure does
|
||||||
|
/// not have to be leaked or re-registered on every pick
|
||||||
|
static PICKER: RefCell<Option<Picker>> = const { RefCell::new(None) };
|
||||||
|
/// The sender the active pick delivers through
|
||||||
|
static PENDING: RefCell<Option<Sender<PickResult>>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Picker {
|
||||||
|
input: HtmlInputElement,
|
||||||
|
/// Keep the change handler alive for the life of the page
|
||||||
|
_on_change: Closure<dyn FnMut()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the user for a single file and read its bytes
|
||||||
|
pub async fn pick_file() -> PickResult {
|
||||||
|
let (sender, receiver) = flume::bounded(1);
|
||||||
|
|
||||||
|
PICKER.with(|picker| -> Result<(), Error> {
|
||||||
|
let mut picker = picker.borrow_mut();
|
||||||
|
if picker.is_none() {
|
||||||
|
*picker = Some(init_picker()?);
|
||||||
|
}
|
||||||
|
let picker = picker.as_ref().expect("picker just initialized");
|
||||||
|
|
||||||
|
PENDING.with(|pending| *pending.borrow_mut() = Some(sender));
|
||||||
|
picker.input.click();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
receiver
|
||||||
|
.recv_async()
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("File picker closed"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_picker() -> Result<Picker, Error> {
|
||||||
|
let document = js_window()
|
||||||
|
.ok_or_else(|| anyhow!("no browser window"))?
|
||||||
|
.document()
|
||||||
|
.ok_or_else(|| anyhow!("no browser document"))?;
|
||||||
|
|
||||||
|
let input = document
|
||||||
|
.create_element("input")
|
||||||
|
.map_err(|e| anyhow!("failed to create file input: {e:?}"))?
|
||||||
|
.dyn_into::<HtmlInputElement>()
|
||||||
|
.map_err(|_| anyhow!("created element is not an input"))?;
|
||||||
|
|
||||||
|
input.set_type("file");
|
||||||
|
input.style().set_property("display", "none").ok();
|
||||||
|
|
||||||
|
let on_change = Closure::wrap(Box::new(move || {
|
||||||
|
let Some(sender) = PENDING.with(|pending| pending.borrow_mut().take()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
sender.send_async(read_selected_file().await).await.ok();
|
||||||
|
});
|
||||||
|
}) as Box<dyn FnMut()>);
|
||||||
|
|
||||||
|
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
|
||||||
|
|
||||||
|
let body = document.body().ok_or_else(|| anyhow!("no browser body"))?;
|
||||||
|
body.append_child(&input)
|
||||||
|
.map_err(|e| anyhow!("failed to mount file input: {e:?}"))?;
|
||||||
|
|
||||||
|
Ok(Picker {
|
||||||
|
input,
|
||||||
|
_on_change: on_change,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the first file of the picker input, if one was chosen
|
||||||
|
async fn read_selected_file() -> PickResult {
|
||||||
|
let picker = PICKER
|
||||||
|
.with(|picker| picker.borrow().as_ref().map(|picker| picker.input.clone()))
|
||||||
|
.ok_or_else(|| anyhow!("file picker is not initialized"))?;
|
||||||
|
|
||||||
|
let Some(file) = picker.files().and_then(|files| files.get(0)) else {
|
||||||
|
// The dialog was dismissed without choosing a file
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mime = non_empty(file.type_());
|
||||||
|
let data = read_file_bytes(&file).await?;
|
||||||
|
let local = crate::file::LocalFile::Url(blob_url(&mime, &data)?);
|
||||||
|
|
||||||
|
Ok(Some(PickedFile {
|
||||||
|
name: file.name(),
|
||||||
|
mime,
|
||||||
|
data,
|
||||||
|
local,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a browser `File` into memory
|
||||||
|
async fn read_file_bytes(file: &File) -> Result<Vec<u8>, Error> {
|
||||||
|
let promise = file.array_buffer();
|
||||||
|
let buffer = wasm_bindgen_futures::JsFuture::from(promise)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("failed to read file: {e:?}"))?;
|
||||||
|
|
||||||
|
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an object URL for bytes, so images and downloads render from memory
|
||||||
|
pub fn blob_url(mime: &str, data: &[u8]) -> Result<String, Error> {
|
||||||
|
let array = js_sys::Uint8Array::from(data);
|
||||||
|
let parts = js_sys::Array::new();
|
||||||
|
parts.push(&array);
|
||||||
|
|
||||||
|
let bag = web_sys::BlobPropertyBag::new();
|
||||||
|
bag.set_type(mime);
|
||||||
|
|
||||||
|
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts.into(), &bag)
|
||||||
|
.map_err(|e| anyhow!("failed to create blob: {e:?}"))?;
|
||||||
|
|
||||||
|
DomUrl::create_object_url_with_blob(&blob).map_err(|e| anyhow!("failed to create url: {e:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offer bytes to the user as a browser download
|
||||||
|
pub fn download(name: &str, mime: &str, data: &[u8]) -> Result<(), Error> {
|
||||||
|
let window = js_window().ok_or_else(|| anyhow!("no browser window"))?;
|
||||||
|
let document = window
|
||||||
|
.document()
|
||||||
|
.ok_or_else(|| anyhow!("no browser document"))?;
|
||||||
|
|
||||||
|
let url = blob_url(mime, data)?;
|
||||||
|
|
||||||
|
let anchor: HtmlAnchorElement = document
|
||||||
|
.create_element("a")
|
||||||
|
.map_err(|e| anyhow!("failed to create download link: {e:?}"))?
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|_| anyhow!("created element is not an anchor"))?;
|
||||||
|
anchor.set_href(&url);
|
||||||
|
anchor.set_download(name);
|
||||||
|
anchor.style().set_property("display", "none").ok();
|
||||||
|
|
||||||
|
let body = document.body().ok_or_else(|| anyhow!("no browser body"))?;
|
||||||
|
body.append_child(&anchor)
|
||||||
|
.map_err(|e| anyhow!("failed to mount download link: {e:?}"))?;
|
||||||
|
anchor.click();
|
||||||
|
body.remove_child(&anchor)
|
||||||
|
.map_err(|e| anyhow!("failed to remove download link: {e:?}"))?;
|
||||||
|
|
||||||
|
// Keep the blob alive briefly so the download is not cancelled
|
||||||
|
let callback = Closure::wrap(Box::new(move || {
|
||||||
|
DomUrl::revoke_object_url(&url).ok();
|
||||||
|
}) as Box<dyn FnMut()>);
|
||||||
|
window
|
||||||
|
.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
|
callback.as_ref().unchecked_ref(),
|
||||||
|
10_000,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!("failed to schedule cleanup: {e:?}"))?;
|
||||||
|
// The closure only runs once, after which it can be released
|
||||||
|
callback.forget();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a URL in a new browser tab
|
||||||
|
pub fn open_url(url: &str) -> Result<(), Error> {
|
||||||
|
js_window()
|
||||||
|
.ok_or_else(|| anyhow!("no browser window"))?
|
||||||
|
.open_with_url_and_target(url, "_blank")
|
||||||
|
.map_err(|e| anyhow!("failed to open url: {e:?}"))?
|
||||||
|
.ok_or_else(|| anyhow!("the browser blocked opening the url"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty(value: impl Into<String>) -> String {
|
||||||
|
let value: String = value.into();
|
||||||
|
|
||||||
|
if value.is_empty() {
|
||||||
|
"application/octet-stream".to_string()
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,12 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
||||||
|
common = { path = "../common" }
|
||||||
|
person = { path = "../person" }
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui-base.workspace = true
|
gpui-base.workspace = true
|
||||||
gpui-kit.workspace = true
|
gpui-kit.workspace = true
|
||||||
|
nostr-sdk.workspace = true
|
||||||
web-time = "1.1"
|
web-time = "1.1"
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|||||||
@@ -1,64 +1,124 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement,
|
App, Div, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
|
||||||
Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
|
SharedString, StyleRefinement, Styled, Window, div, px,
|
||||||
SharedString, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
|
|
||||||
};
|
};
|
||||||
|
use gpui_base::AvatarImage;
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::menu::DropdownMenu;
|
use gpui_kit::component::menu::DropdownMenu;
|
||||||
use gpui_kit::component::{ActiveTheme as _, Selectable, Sizable, Size};
|
use gpui_kit::component::{ActiveTheme, Selectable, Sizable, Size};
|
||||||
|
|
||||||
/// Number of rows and columns in the generated pixel grid.
|
/// Number of rows and columns in the pixel grid.
|
||||||
const PIXEL_GRID: usize = 8;
|
const GRID_SIZE: usize = 8;
|
||||||
/// Empty cells kept between the pattern and the avatar edge.
|
/// Empty cells kept between the pattern and the avatar edge.
|
||||||
const MARGIN: usize = 1;
|
const MARGIN: usize = 1;
|
||||||
/// Probability that a cell in the left half of the pattern area is filled.
|
/// Probability that a cell in the left half of the pattern area is filled.
|
||||||
const FILL_PROBABILITY: f32 = 0.42;
|
const FILL_PROBABILITY: f32 = 0.42;
|
||||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||||
/// Minimum number of filled left-half cells, so a pattern never reads as empty.
|
/// Minimum number of filled left-half cells.
|
||||||
const MIN_FILLED: usize = 5;
|
const MIN_FILLED: usize = 5;
|
||||||
/// Fallback seed for an avatar that has neither a picture nor a seed of its own.
|
/// Seed for an avatar that has neither a picture nor a seed of its own.
|
||||||
const FALLBACK_SEED: &str = "coop";
|
const FALLBACK_SEED: &str = "coop";
|
||||||
/// Number of segments used to approximate the avatar circle.
|
|
||||||
const CIRCLE_SEGMENTS: usize = 32;
|
|
||||||
|
|
||||||
/// Returns the size of the avatar based on the given [`Size].
|
/// An avatar for a user or other entity.
|
||||||
fn avatar_size(size: Size) -> AbsoluteLength {
|
|
||||||
match size {
|
|
||||||
Size::Large => px(64.).into(),
|
|
||||||
Size::Medium => px(32.).into(),
|
|
||||||
Size::Small => px(24.).into(),
|
|
||||||
Size::XSmall => px(18.).into(),
|
|
||||||
Size::Size(size) => size.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A deterministic, offline pixel-art avatar derived from a seed.
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
struct PixelAvatar {
|
pub struct Avatar {
|
||||||
seed: u64,
|
base: Div,
|
||||||
|
picture: Option<SharedString>,
|
||||||
|
seed: Option<String>,
|
||||||
size: Size,
|
size: Size,
|
||||||
|
selected: bool,
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PixelAvatar {
|
impl Avatar {
|
||||||
/// Creates a pixel avatar from the given seed.
|
/// Create an avatar showing `picture`, falling back to pixel art seeded by [`Avatar::seed`].
|
||||||
fn new(seed: impl AsRef<str>) -> Self {
|
pub fn new(picture: Option<SharedString>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
base: div(),
|
||||||
|
picture,
|
||||||
|
seed: None,
|
||||||
size: Size::Medium,
|
size: Size::Medium,
|
||||||
|
selected: false,
|
||||||
style: StyleRefinement::default(),
|
style: StyleRefinement::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The seed for the generated pixel avatar.
|
||||||
|
pub fn seed(mut self, seed: impl Into<String>) -> Self {
|
||||||
|
self.seed = Some(seed.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sizable for PixelAvatar {
|
impl Selectable for Avatar {
|
||||||
|
fn selected(mut self, selected: bool) -> Self {
|
||||||
|
self.selected = selected;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_selected(&self) -> bool {
|
||||||
|
self.selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sizable for Avatar {
|
||||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||||
self.size = size.into();
|
self.size = size.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Styled for Avatar {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InteractiveElement for Avatar {
|
||||||
|
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||||
|
self.base.interactivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DropdownMenu for Avatar {}
|
||||||
|
|
||||||
|
impl RenderOnce for Avatar {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let side = side_length(self.size);
|
||||||
|
|
||||||
|
let content = match self.picture {
|
||||||
|
Some(url) => div()
|
||||||
|
.size_full()
|
||||||
|
.child(AvatarImage::new(url).size_full().rounded_full())
|
||||||
|
.into_any_element(),
|
||||||
|
None => PixelAvatar {
|
||||||
|
seed: fnv1a(self.seed.unwrap_or_else(|| FALLBACK_SEED.into()).as_bytes()),
|
||||||
|
side,
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
}
|
||||||
|
.into_any_element(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.base
|
||||||
|
.size(side)
|
||||||
|
.flex_shrink_0()
|
||||||
|
.rounded_full()
|
||||||
|
.overflow_hidden()
|
||||||
|
.bg(cx.theme().secondary)
|
||||||
|
.child(content)
|
||||||
|
.refine_style(&self.style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deterministic, offline pixel-art avatar.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
struct PixelAvatar {
|
||||||
|
seed: u64,
|
||||||
|
side: Pixels,
|
||||||
|
style: StyleRefinement,
|
||||||
|
}
|
||||||
|
|
||||||
impl Styled for PixelAvatar {
|
impl Styled for PixelAvatar {
|
||||||
fn style(&mut self) -> &mut StyleRefinement {
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
&mut self.style
|
&mut self.style
|
||||||
@@ -66,24 +126,18 @@ impl Styled for PixelAvatar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for PixelAvatar {
|
impl RenderOnce for PixelAvatar {
|
||||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let side = avatar_size(self.size).to_pixels(window.rem_size());
|
|
||||||
let seed = self.seed;
|
|
||||||
|
|
||||||
div()
|
|
||||||
.refine_style(&self.style)
|
|
||||||
.size(side)
|
|
||||||
.flex_shrink_0()
|
|
||||||
.child(canvas(
|
|
||||||
move |_bounds, _window, _cx| seed,
|
|
||||||
move |bounds, seed, window, cx| {
|
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
|
let pattern = pattern(self.seed);
|
||||||
|
let mut cells = Vec::new();
|
||||||
|
|
||||||
let main = Hsla {
|
let main = Hsla {
|
||||||
h: seed as f32 / u64::MAX as f32,
|
h: self.seed as f32 / u64::MAX as f32,
|
||||||
s: 0.6,
|
s: 0.6,
|
||||||
l: if theme.is_dark() { 0.6 } else { 0.45 },
|
l: if theme.is_dark() { 0.6 } else { 0.45 },
|
||||||
a: 1.,
|
a: 1.,
|
||||||
};
|
};
|
||||||
|
|
||||||
let shade = if theme.is_dark() {
|
let shade = if theme.is_dark() {
|
||||||
Hsla {
|
Hsla {
|
||||||
l: (main.l * 1.6).min(0.95),
|
l: (main.l * 1.6).min(0.95),
|
||||||
@@ -96,46 +150,65 @@ impl RenderOnce for PixelAvatar {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let circle = circle_polygon(bounds.center(), bounds.size.width.as_f32() / 2.);
|
for row in 0..GRID_SIZE {
|
||||||
paint_polygons(window, std::iter::once(&circle), main.opacity(0.16));
|
for col in 0..GRID_SIZE {
|
||||||
|
let value = pattern[row * GRID_SIZE + col];
|
||||||
let pattern = pixel_pattern(seed);
|
if value != 0 {
|
||||||
let mut cells = Vec::new();
|
let color = if value == 2 { shade } else { main };
|
||||||
|
cells.push(
|
||||||
for (value, color) in [(1u8, main), (2u8, shade)] {
|
div()
|
||||||
cells.clear();
|
.row_start(row as i16 + 1)
|
||||||
|
.row_end(row as i16 + 2)
|
||||||
for row in 0..PIXEL_GRID {
|
.col_start(col as i16 + 1)
|
||||||
for col in 0..PIXEL_GRID {
|
.col_end(col as i16 + 2)
|
||||||
if pattern[row * PIXEL_GRID + col] != value {
|
.bg(color),
|
||||||
continue;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
let cell = clip_polygon(&cell_polygon(&bounds, row, col), &circle);
|
|
||||||
if cell.len() >= 3 {
|
|
||||||
cells.push(cell);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
paint_polygons(window, cells.iter(), color);
|
// Pad the grid so the art stays clear of the circular edge, keeping the
|
||||||
}
|
// inset look of signed's rounded-square frame.
|
||||||
},
|
let inset = self.side / (GRID_SIZE as f32 * 2.);
|
||||||
))
|
|
||||||
|
div()
|
||||||
|
.grid()
|
||||||
|
.grid_cols(GRID_SIZE as u16)
|
||||||
|
.grid_rows(GRID_SIZE as u16)
|
||||||
|
.size(self.side)
|
||||||
|
.p(inset)
|
||||||
|
.flex_shrink_0()
|
||||||
|
.rounded_full()
|
||||||
|
.overflow_hidden()
|
||||||
|
.bg(main.opacity(0.16))
|
||||||
|
.children(cells)
|
||||||
|
.refine_style(&self.style)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the mirrored fill pattern for the given seed.
|
/// The rendered side length of an avatar at `size`.
|
||||||
fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
|
fn side_length(size: Size) -> Pixels {
|
||||||
|
match size {
|
||||||
|
Size::XSmall => px(16.),
|
||||||
|
Size::Small => px(24.),
|
||||||
|
Size::Medium => px(48.),
|
||||||
|
Size::Large => px(80.),
|
||||||
|
Size::Size(size) => size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||||
let mut rng = PixelRng::new(seed);
|
let mut rng = PixelRng::new(seed);
|
||||||
let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID];
|
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||||
let mut filled = 0usize;
|
let mut filled = 0usize;
|
||||||
|
|
||||||
let art_rows = PIXEL_GRID - 2 * MARGIN;
|
// Only the inner rows and the inner left half are candidates; mirroring
|
||||||
let art_columns = PIXEL_GRID / 2 - MARGIN;
|
// then keeps the art within the same inset, leaving the outer ring empty.
|
||||||
|
let art_rows = GRID_SIZE - 2 * MARGIN;
|
||||||
|
let art_columns = GRID_SIZE / 2 - MARGIN;
|
||||||
|
|
||||||
for row in MARGIN..PIXEL_GRID - MARGIN {
|
for row in MARGIN..GRID_SIZE - MARGIN {
|
||||||
for col in MARGIN..PIXEL_GRID / 2 {
|
for col in MARGIN..GRID_SIZE / 2 {
|
||||||
if rng.chance(FILL_PROBABILITY) {
|
if rng.chance(FILL_PROBABILITY) {
|
||||||
let accent = rng.chance(ACCENT_PROBABILITY);
|
let accent = rng.chance(ACCENT_PROBABILITY);
|
||||||
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
||||||
@@ -157,7 +230,7 @@ fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
|
|||||||
let row = MARGIN + ix / art_columns;
|
let row = MARGIN + ix / art_columns;
|
||||||
let col = MARGIN + ix % art_columns;
|
let col = MARGIN + ix % art_columns;
|
||||||
|
|
||||||
if pattern[row * PIXEL_GRID + col] == 0 {
|
if pattern[row * GRID_SIZE + col] == 0 {
|
||||||
set_cell(&mut pattern, row, col, 1);
|
set_cell(&mut pattern, row, col, 1);
|
||||||
filled += 1;
|
filled += 1;
|
||||||
}
|
}
|
||||||
@@ -167,146 +240,10 @@ fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
|
|||||||
pattern
|
pattern
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paints `polygons as a single anti-aliased filled path in `color.
|
/// Fill `cell (row, col)` and its horizontal mirror.
|
||||||
fn paint_polygons<'a>(
|
fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, value: u8) {
|
||||||
window: &mut Window,
|
pattern[row * GRID_SIZE + col] = value;
|
||||||
polygons: impl IntoIterator<Item = &'a Vec<Point<Pixels>>>,
|
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value;
|
||||||
color: Hsla,
|
|
||||||
) {
|
|
||||||
let mut builder = PathBuilder::fill();
|
|
||||||
let mut painted = false;
|
|
||||||
|
|
||||||
for polygon in polygons {
|
|
||||||
if polygon.len() >= 3 {
|
|
||||||
builder.add_polygon(polygon, true);
|
|
||||||
painted = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if painted && let Ok(path) = builder.build() {
|
|
||||||
window.paint_path(path, color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Approximates the circle of `radius around `center as a convex polygon wound left-interior.
|
|
||||||
fn circle_polygon(center: Point<Pixels>, radius: f32) -> Vec<Point<Pixels>> {
|
|
||||||
let center_x = center.x.as_f32();
|
|
||||||
let center_y = center.y.as_f32();
|
|
||||||
|
|
||||||
(0..CIRCLE_SEGMENTS)
|
|
||||||
.map(|index| {
|
|
||||||
let angle = std::f32::consts::TAU * index as f32 / CIRCLE_SEGMENTS as f32;
|
|
||||||
point(
|
|
||||||
px(center_x + radius * angle.cos()),
|
|
||||||
px(center_y + radius * angle.sin()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The four corners of cell `(row, col) of the grid laid out in `bounds.
|
|
||||||
fn cell_polygon(bounds: &Bounds<Pixels>, row: usize, col: usize) -> [Point<Pixels>; 4] {
|
|
||||||
let cell = bounds.size.width.as_f32() / PIXEL_GRID as f32;
|
|
||||||
let left = bounds.origin.x.as_f32() + col as f32 * cell;
|
|
||||||
let top = bounds.origin.y.as_f32() + row as f32 * cell;
|
|
||||||
|
|
||||||
[
|
|
||||||
point(px(left), px(top)),
|
|
||||||
point(px(left + cell), px(top)),
|
|
||||||
point(px(left + cell), px(top + cell)),
|
|
||||||
point(px(left), px(top + cell)),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clips `subject to the convex `clip polygon, keeping the part inside it.
|
|
||||||
fn clip_polygon(subject: &[Point<Pixels>], clip: &[Point<Pixels>]) -> Vec<Point<Pixels>> {
|
|
||||||
let mut current = subject.to_vec();
|
|
||||||
let mut next = Vec::with_capacity(subject.len() + 4);
|
|
||||||
|
|
||||||
for (&start, &end) in clip.iter().zip(clip.iter().cycle().skip(1)) {
|
|
||||||
if current.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
next.clear();
|
|
||||||
let mut previous = match current.last() {
|
|
||||||
Some(&vertex) => vertex,
|
|
||||||
None => break,
|
|
||||||
};
|
|
||||||
|
|
||||||
for &vertex in current.iter() {
|
|
||||||
let previous_inside = is_inside(start, end, previous);
|
|
||||||
let vertex_inside = is_inside(start, end, vertex);
|
|
||||||
|
|
||||||
if vertex_inside {
|
|
||||||
if !previous_inside
|
|
||||||
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
|
|
||||||
{
|
|
||||||
next.push(crossing);
|
|
||||||
}
|
|
||||||
next.push(vertex);
|
|
||||||
} else if previous_inside
|
|
||||||
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
|
|
||||||
{
|
|
||||||
next.push(crossing);
|
|
||||||
}
|
|
||||||
|
|
||||||
previous = vertex;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::mem::swap(&mut current, &mut next);
|
|
||||||
}
|
|
||||||
|
|
||||||
current
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `vertex lies on the interior side of the directed edge `start -> end.
|
|
||||||
fn is_inside(start: Point<Pixels>, end: Point<Pixels>, vertex: Point<Pixels>) -> bool {
|
|
||||||
let start_x = start.x.as_f32();
|
|
||||||
let start_y = start.y.as_f32();
|
|
||||||
let edge_x = end.x.as_f32() - start_x;
|
|
||||||
let edge_y = end.y.as_f32() - start_y;
|
|
||||||
let to_vertex_x = vertex.x.as_f32() - start_x;
|
|
||||||
let to_vertex_y = vertex.y.as_f32() - start_y;
|
|
||||||
|
|
||||||
edge_x * to_vertex_y - edge_y * to_vertex_x >= 0.
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The intersection of segment `from -> to with the infinite line `start -> end.
|
|
||||||
fn line_intersection(
|
|
||||||
start: Point<Pixels>,
|
|
||||||
end: Point<Pixels>,
|
|
||||||
from: Point<Pixels>,
|
|
||||||
to: Point<Pixels>,
|
|
||||||
) -> Option<Point<Pixels>> {
|
|
||||||
let start_x = start.x.as_f32();
|
|
||||||
let start_y = start.y.as_f32();
|
|
||||||
let edge_x = end.x.as_f32() - start_x;
|
|
||||||
let edge_y = end.y.as_f32() - start_y;
|
|
||||||
let from_x = from.x.as_f32();
|
|
||||||
let from_y = from.y.as_f32();
|
|
||||||
let segment_x = to.x.as_f32() - from_x;
|
|
||||||
let segment_y = to.y.as_f32() - from_y;
|
|
||||||
let denominator = edge_x * segment_y - edge_y * segment_x;
|
|
||||||
|
|
||||||
if denominator.abs() < f32::EPSILON {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let offset_x = from_x - start_x;
|
|
||||||
let offset_y = from_y - start_y;
|
|
||||||
let t = (edge_y * offset_x - edge_x * offset_y) / denominator;
|
|
||||||
|
|
||||||
Some(point(
|
|
||||||
px(from_x + segment_x * t),
|
|
||||||
px(from_y + segment_y * t),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fills `cell (row, col) and its horizontal mirror.
|
|
||||||
fn set_cell(pattern: &mut [u8; PIXEL_GRID * PIXEL_GRID], row: usize, col: usize, value: u8) {
|
|
||||||
pattern[row * PIXEL_GRID + col] = value;
|
|
||||||
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)] = value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
||||||
@@ -340,109 +277,3 @@ impl PixelRng {
|
|||||||
self.next() as f32 / (u64::MAX as f32) < probability
|
self.next() as f32 / (u64::MAX as f32) < probability
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders the generated pixel avatar shown in place of a missing picture.
|
|
||||||
fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement {
|
|
||||||
PixelAvatar::new(seed.unwrap_or(FALLBACK_SEED))
|
|
||||||
.with_size(size)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An element that renders a user avatar with customizable appearance options.
|
|
||||||
#[derive(IntoElement)]
|
|
||||||
pub struct Avatar {
|
|
||||||
base: Div,
|
|
||||||
picture: Option<ImageSource>,
|
|
||||||
seed: Option<String>,
|
|
||||||
style: StyleRefinement,
|
|
||||||
size: Size,
|
|
||||||
selected: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Avatar {
|
|
||||||
/// Creates an avatar for an entity whose profile picture may be missing.
|
|
||||||
pub fn new(picture: Option<SharedString>) -> Self {
|
|
||||||
Self::from_source(picture)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates an avatar from an already-resolved source, which may be missing.
|
|
||||||
pub fn from_source(picture: Option<impl Into<ImageSource>>) -> Self {
|
|
||||||
Self::from_picture(picture.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_picture(picture: Option<ImageSource>) -> Self {
|
|
||||||
Avatar {
|
|
||||||
base: div(),
|
|
||||||
picture,
|
|
||||||
seed: None,
|
|
||||||
style: StyleRefinement::default(),
|
|
||||||
size: Size::Medium,
|
|
||||||
selected: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the seed for the generated pixel avatar.
|
|
||||||
pub fn seed(mut self, seed: impl Into<String>) -> Self {
|
|
||||||
self.seed = Some(seed.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Sizable for Avatar {
|
|
||||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
|
||||||
self.size = size.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Styled for Avatar {
|
|
||||||
fn style(&mut self) -> &mut StyleRefinement {
|
|
||||||
&mut self.style
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Selectable for Avatar {
|
|
||||||
fn is_selected(&self) -> bool {
|
|
||||||
self.selected
|
|
||||||
}
|
|
||||||
|
|
||||||
fn selected(mut self, selected: bool) -> Self {
|
|
||||||
self.selected = selected;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InteractiveElement for Avatar {
|
|
||||||
fn interactivity(&mut self) -> &mut Interactivity {
|
|
||||||
self.base.interactivity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DropdownMenu for Avatar {}
|
|
||||||
|
|
||||||
impl RenderOnce for Avatar {
|
|
||||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
|
||||||
let image_size = avatar_size(self.size).to_pixels(window.rem_size());
|
|
||||||
|
|
||||||
let content = match self.picture {
|
|
||||||
Some(picture) => {
|
|
||||||
let seed = self.seed;
|
|
||||||
|
|
||||||
img(picture)
|
|
||||||
.size(image_size)
|
|
||||||
.rounded_full()
|
|
||||||
.object_fit(ObjectFit::Cover)
|
|
||||||
.with_fallback(move || generated_avatar(seed.as_deref(), image_size))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
None => generated_avatar(self.seed.as_deref(), image_size),
|
|
||||||
};
|
|
||||||
|
|
||||||
div()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.size(image_size)
|
|
||||||
.rounded_full()
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use gpui::{App, Div, IntoElement, ParentElement, SharedString, Styled, div};
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
|
use gpui_kit::component::{ActiveTheme, h_flex, v_flex};
|
||||||
|
|
||||||
|
/// A settings row: the label and description on the left, the control on the right.
|
||||||
|
pub fn settings_row(
|
||||||
|
label: impl Into<SharedString>,
|
||||||
|
description: impl Into<SharedString>,
|
||||||
|
control: impl IntoElement,
|
||||||
|
cx: &App,
|
||||||
|
) -> Div {
|
||||||
|
h_flex()
|
||||||
|
.w_full()
|
||||||
|
.gap_4()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.child(div().text_sm().font_semibold().child(label.into()))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(description.into()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.w_40()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.justify_end()
|
||||||
|
.items_center()
|
||||||
|
.child(control),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full-width settings block with the label and description in one header.
|
||||||
|
pub fn settings_block(
|
||||||
|
label: impl Into<SharedString>,
|
||||||
|
description: impl Into<SharedString>,
|
||||||
|
control: impl IntoElement,
|
||||||
|
cx: &App,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
v_flex()
|
||||||
|
.w_full()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.w_full()
|
||||||
|
.child(div().text_sm().font_semibold().child(label.into()))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.truncate()
|
||||||
|
.child(description.into()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(control)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
use gpui::SharedString;
|
||||||
|
use gpui_kit::assets::IconNamed;
|
||||||
|
|
||||||
|
/// Coop icons kept from before the gpui-kit migration; absent from the kit bundle.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum IconName {
|
||||||
|
ArrowDownCircle,
|
||||||
|
ArrowLeft,
|
||||||
|
Book,
|
||||||
|
CaretDown,
|
||||||
|
CaretRight,
|
||||||
|
CheckCircle,
|
||||||
|
Close,
|
||||||
|
Compass,
|
||||||
|
Door,
|
||||||
|
Emoji,
|
||||||
|
Group,
|
||||||
|
Input,
|
||||||
|
Invite,
|
||||||
|
Link,
|
||||||
|
Lock,
|
||||||
|
Message,
|
||||||
|
Paperclip,
|
||||||
|
PaperPlaneFill,
|
||||||
|
Plus,
|
||||||
|
PlusChat,
|
||||||
|
Profile,
|
||||||
|
Relay,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Settings2,
|
||||||
|
Upload,
|
||||||
|
Warning,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IconNamed for IconName {
|
||||||
|
fn path(self) -> SharedString {
|
||||||
|
match self {
|
||||||
|
Self::ArrowDownCircle => "icons/arrow-down-circle.svg",
|
||||||
|
Self::ArrowLeft => "icons/arrow-left.svg",
|
||||||
|
Self::Book => "icons/book.svg",
|
||||||
|
Self::CaretDown => "icons/caret-down.svg",
|
||||||
|
Self::CaretRight => "icons/caret-right.svg",
|
||||||
|
Self::CheckCircle => "icons/check-circle.svg",
|
||||||
|
Self::Close => "icons/close.svg",
|
||||||
|
Self::Compass => "icons/compass.svg",
|
||||||
|
Self::Door => "icons/door.svg",
|
||||||
|
Self::Emoji => "icons/emoji.svg",
|
||||||
|
Self::Group => "icons/group.svg",
|
||||||
|
Self::Input => "icons/input.svg",
|
||||||
|
Self::Invite => "icons/invite.svg",
|
||||||
|
Self::Link => "icons/link.svg",
|
||||||
|
Self::Lock => "icons/lock.svg",
|
||||||
|
Self::Message => "icons/message.svg",
|
||||||
|
Self::Paperclip => "icons/paperclip.svg",
|
||||||
|
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
|
||||||
|
Self::Plus => "icons/plus.svg",
|
||||||
|
Self::PlusChat => "icons/plus-chat.svg",
|
||||||
|
Self::Profile => "icons/profile.svg",
|
||||||
|
Self::Relay => "icons/relay.svg",
|
||||||
|
Self::Search => "icons/search.svg",
|
||||||
|
Self::Settings => "icons/settings.svg",
|
||||||
|
Self::Settings2 => "icons/settings2.svg",
|
||||||
|
Self::Upload => "icons/upload.svg",
|
||||||
|
Self::Warning => "icons/warning.svg",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
pub use avatar::Avatar;
|
pub use avatar::Avatar;
|
||||||
|
pub use form::*;
|
||||||
|
pub use icon::IconName;
|
||||||
|
pub use message::*;
|
||||||
|
pub use text::*;
|
||||||
pub use window_drag::title_bar_drag_handlers;
|
pub use window_drag::title_bar_drag_handlers;
|
||||||
|
|
||||||
pub mod avatar;
|
pub mod avatar;
|
||||||
|
pub mod form;
|
||||||
|
pub mod icon;
|
||||||
|
pub mod message;
|
||||||
|
pub mod text;
|
||||||
pub mod window_drag;
|
pub mod window_drag;
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, MouseButton,
|
AnyElement, App, ClickEvent, Context, ElementId, InteractiveElement as _, IntoElement,
|
||||||
MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString, SharedUri,
|
MouseButton, MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString,
|
||||||
StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div, img, px,
|
SharedUri, StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div,
|
||||||
|
img, px,
|
||||||
};
|
};
|
||||||
|
use gpui_kit::assets::IconName;
|
||||||
|
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||||
|
use gpui_kit::component::clipboard::Clipboard;
|
||||||
|
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
|
||||||
use gpui_kit::component::{
|
use gpui_kit::component::{
|
||||||
ActiveTheme, InteractiveElementExt as _, StyledExt as _, h_flex, v_flex,
|
ActiveTheme, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex, v_flex,
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
pub use ui::Avatar;
|
|
||||||
|
|
||||||
type MouseDownListener = Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
|
type MouseDownListener = Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
|
||||||
type ClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
type ClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
||||||
|
type SharedClickListener = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
||||||
|
type DropdownBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>;
|
||||||
|
|
||||||
/// Messages from the same author within this window are grouped into one run.
|
/// Messages from the same author within this window are grouped into one run.
|
||||||
pub const RUN_WINDOW_SECS: u64 = 300;
|
pub const RUN_WINDOW_SECS: u64 = 300;
|
||||||
@@ -179,7 +185,7 @@ impl RenderOnce for MessageRow {
|
|||||||
.group("")
|
.group("")
|
||||||
.relative()
|
.relative()
|
||||||
.w_full()
|
.w_full()
|
||||||
.py_1()
|
.py_0p5()
|
||||||
.when(show_author, |this| this.pt_2())
|
.when(show_author, |this| this.pt_2())
|
||||||
.px_3()
|
.px_3()
|
||||||
.refine_style(&style)
|
.refine_style(&style)
|
||||||
@@ -191,10 +197,10 @@ impl RenderOnce for MessageRow {
|
|||||||
if show_author {
|
if show_author {
|
||||||
match avatar {
|
match avatar {
|
||||||
Some(avatar) => this.child(avatar),
|
Some(avatar) => this.child(avatar),
|
||||||
None => this.child(div().flex_shrink_0().w(px(32.))),
|
None => this.child(div().flex_shrink_0().w(px(24.))),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.child(div().flex_shrink_0().w(px(32.)))
|
this.child(div().flex_shrink_0().w(px(24.)))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
@@ -204,6 +210,7 @@ impl RenderOnce for MessageRow {
|
|||||||
.min_w_0()
|
.min_w_0()
|
||||||
.flex_initial()
|
.flex_initial()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
|
.text_size(px(15.))
|
||||||
.when(show_author, |this| {
|
.when(show_author, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -211,11 +218,7 @@ impl RenderOnce for MessageRow {
|
|||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.when_some(author, |this, author| {
|
.when_some(author, |this, author| {
|
||||||
this.child(
|
this.child(div().font_semibold().child(author))
|
||||||
div()
|
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.child(author),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.when_some(timestamp, |this, timestamp| {
|
.when_some(timestamp, |this, timestamp| {
|
||||||
this.child(timestamp)
|
this.child(timestamp)
|
||||||
@@ -233,7 +236,7 @@ impl RenderOnce for MessageRow {
|
|||||||
.when_some(on_double_click, |this, listener| {
|
.when_some(on_double_click, |this, listener| {
|
||||||
this.on_double_click(listener)
|
this.on_double_click(listener)
|
||||||
})
|
})
|
||||||
.hover(|this| this.bg(cx.theme().accent))
|
.hover(|this| this.bg(cx.theme().list_hover))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,9 +302,9 @@ impl RenderOnce for ReplyPreview {
|
|||||||
.border_color(cx.theme().list_active)
|
.border_color(cx.theme().list_active)
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.refine_style(&style)
|
.refine_style(&style)
|
||||||
.child(div().font_weight(gpui::FontWeight::SEMIBOLD).child(author))
|
.child(div().font_semibold().child(author))
|
||||||
.child(div().w_full().text_ellipsis().line_clamp(1).child(preview))
|
.child(div().w_full().text_ellipsis().line_clamp(1).child(preview))
|
||||||
.hover(|this| this.bg(cx.theme().secondary))
|
.hover(|this| this.bg(cx.theme().list_hover))
|
||||||
.when_some(on_click, |this, on_click| this.on_click(on_click))
|
.when_some(on_click, |this, on_click| this.on_click(on_click))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -558,3 +561,168 @@ impl RenderOnce for WelcomeMessage {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Quick reaction emojis offered in message menus.
|
||||||
|
pub const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
||||||
|
|
||||||
|
/// Quick reaction emojis shown on a hovered message's action bar.
|
||||||
|
pub const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
|
||||||
|
|
||||||
|
/// Fill a popup menu with reactions to a message, offering `emojis` then the custom ones.
|
||||||
|
///
|
||||||
|
/// `react` receives the reaction content: an emoji, or a `:shortcode:` for a custom one.
|
||||||
|
pub fn react_menu(
|
||||||
|
menu: PopupMenu,
|
||||||
|
emojis: &[&'static str],
|
||||||
|
custom: &[String],
|
||||||
|
react: impl Fn(&str, &mut Window, &mut App) + Clone + 'static,
|
||||||
|
) -> PopupMenu {
|
||||||
|
let menu = emojis.iter().fold(menu, |menu, emoji| {
|
||||||
|
let emoji = *emoji;
|
||||||
|
let react = react.clone();
|
||||||
|
menu.item(PopupMenuItem::new(emoji).on_click(move |_, window, cx| react(emoji, window, cx)))
|
||||||
|
});
|
||||||
|
|
||||||
|
if custom.is_empty() {
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
custom.iter().fold(menu.separator(), |menu, emoji| {
|
||||||
|
let emoji = emoji.clone();
|
||||||
|
let react = react.clone();
|
||||||
|
menu.item(
|
||||||
|
PopupMenuItem::new(emoji.clone())
|
||||||
|
.on_click(move |_, window, cx| react(&emoji, window, cx)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hovered message's action bar: quick reactions, reply, copy, and an overflow menu.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct MessageActions {
|
||||||
|
style: StyleRefinement,
|
||||||
|
reactions: SmallVec<[(&'static str, SharedClickListener); 4]>,
|
||||||
|
reply: Option<SharedClickListener>,
|
||||||
|
copy: Option<(ElementId, SharedString)>,
|
||||||
|
dropdown: Option<DropdownBuilder>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageActions {
|
||||||
|
/// Create an empty action bar.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
reactions: SmallVec::new(),
|
||||||
|
reply: None,
|
||||||
|
copy: None,
|
||||||
|
dropdown: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a quick reaction button.
|
||||||
|
pub fn reaction(
|
||||||
|
mut self,
|
||||||
|
emoji: &'static str,
|
||||||
|
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.reactions.push((emoji, Rc::new(handler)));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add the reply button.
|
||||||
|
pub fn reply(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
|
||||||
|
self.reply = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add the copy button for the given value.
|
||||||
|
/// The id must be unique per bar, the clipboard component keys its copied state on it.
|
||||||
|
pub fn copy(mut self, id: impl Into<ElementId>, value: impl Into<SharedString>) -> Self {
|
||||||
|
self.copy = Some((id.into(), value.into()));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill the bar's overflow menu.
|
||||||
|
pub fn dropdown(
|
||||||
|
mut self,
|
||||||
|
dropdown: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.dropdown = Some(Rc::new(dropdown));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MessageActions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for MessageActions {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for MessageActions {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let Self {
|
||||||
|
style,
|
||||||
|
reactions,
|
||||||
|
reply,
|
||||||
|
copy,
|
||||||
|
dropdown,
|
||||||
|
} = self;
|
||||||
|
|
||||||
|
let has_center = reply.is_some() || copy.is_some();
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.p_0p5()
|
||||||
|
.gap_1()
|
||||||
|
.invisible()
|
||||||
|
.absolute()
|
||||||
|
.right_4()
|
||||||
|
.top_neg_2()
|
||||||
|
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||||
|
.rounded(cx.theme().radius_lg)
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.bg(cx.theme().background)
|
||||||
|
.children(reactions.into_iter().map(|(emoji, handler)| {
|
||||||
|
Button::new(emoji)
|
||||||
|
.label(emoji)
|
||||||
|
.tooltip(emoji)
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.on_click(move |event, window, cx| handler(event, window, cx))
|
||||||
|
}))
|
||||||
|
.when(has_center, |this| {
|
||||||
|
this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
||||||
|
})
|
||||||
|
.when_some(reply, |this, reply| {
|
||||||
|
this.child(
|
||||||
|
Button::new("reply")
|
||||||
|
.icon(IconName::Reply)
|
||||||
|
.tooltip("Reply")
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.on_click(move |event, window, cx| reply(event, window, cx)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when_some(copy, |this, (id, value)| {
|
||||||
|
this.child(Clipboard::new(id).value(value).tooltip("Copy").small())
|
||||||
|
})
|
||||||
|
.when_some(dropdown, |this, dropdown| {
|
||||||
|
this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
||||||
|
.child(
|
||||||
|
Button::new("more")
|
||||||
|
.icon(IconName::Ellipsis)
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.dropdown_menu(move |menu, window, cx| dropdown(menu, window, cx)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.group_hover("", |this| this.visible())
|
||||||
|
.refine_style(&style)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use chat::{Mention, nevent_refs};
|
use common::nevent_refs;
|
||||||
use gpui::{AnyElement, App, ElementId, Entity, IntoElement as _, SharedString, SharedUri, Window};
|
use gpui::{AnyElement, App, ElementId, Entity, IntoElement as _, SharedString, SharedUri, Window};
|
||||||
use gpui_kit::component::text::TextView;
|
use gpui_kit::component::text::TextView;
|
||||||
use nostr_sdk::prelude::Url;
|
use nostr_sdk::prelude::{PublicKey, Url};
|
||||||
use person::Directory;
|
use person::Directory;
|
||||||
|
|
||||||
/// A span of the source content, by byte range, that renders as `text` instead.
|
/// A span of the source content, by byte range, that renders as `text` instead.
|
||||||
@@ -121,22 +121,22 @@ fn nevent_bech32(url: &str) -> Option<&str> {
|
|||||||
bech32.starts_with("nevent1").then_some(bech32)
|
bech32.starts_with("nevent1").then_some(bech32)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Composes a message's rendered text: mention names, NIP-30 emojis, and nevent links.
|
||||||
|
///
|
||||||
|
/// `mentions` are the `(byte range, mentioned public key)` pairs found in `content`.
|
||||||
pub fn rendered_text(
|
pub fn rendered_text(
|
||||||
content: &str,
|
content: &str,
|
||||||
mentions: &[Mention],
|
mentions: impl IntoIterator<Item = (Range<usize>, PublicKey)>,
|
||||||
emojis: &[(String, Url)],
|
emojis: &[(String, Url)],
|
||||||
persons: &Entity<Directory>,
|
persons: &Entity<Directory>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
let mut replacements = mentions
|
let mut replacements = mentions
|
||||||
.iter()
|
.into_iter()
|
||||||
.map(|mention| {
|
.map(|(range, public_key)| {
|
||||||
InlineReplacement::new(
|
InlineReplacement::new(
|
||||||
mention.range.clone(),
|
range,
|
||||||
format!(
|
format!("@{}", persons.read(cx).person(&public_key, cx).name()),
|
||||||
"@{}",
|
|
||||||
persons.read(cx).person(&mention.public_key, cx).name()
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -15,6 +15,7 @@ chat_ui = { path = "../chat_ui" }
|
|||||||
nip29 = { path = "../nip29" }
|
nip29 = { path = "../nip29" }
|
||||||
nip29_ui = { path = "../nip29_ui" }
|
nip29_ui = { path = "../nip29_ui" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
speech = { path = "../speech" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
auto_update = { path = "../auto_update" }
|
auto_update = { path = "../auto_update" }
|
||||||
|
|
||||||
@@ -27,4 +28,5 @@ nostr-connect.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use gpui_kit::component::input::{Input, InputEvent, InputState};
|
|||||||
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use state::NostrRegistry;
|
use state::{NostrRegistry, StateEvent};
|
||||||
|
|
||||||
pub fn open(window: &mut Window, cx: &mut App) {
|
pub fn open(window: &mut Window, cx: &mut App) {
|
||||||
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
||||||
@@ -29,8 +29,7 @@ pub fn open_unlock(window: &mut Window, cx: &mut App) {
|
|||||||
let import = cx.new(|cx| ImportIdentity::new_unlock(window, cx));
|
let import = cx.new(|cx| ImportIdentity::new_unlock(window, cx));
|
||||||
|
|
||||||
window.open_dialog(cx, move |this, _window, _cx| {
|
window.open_dialog(cx, move |this, _window, _cx| {
|
||||||
this.width(px(450.))
|
this.close_button(false)
|
||||||
.close_button(false)
|
|
||||||
.overlay_closable(false)
|
.overlay_closable(false)
|
||||||
.keyboard(false)
|
.keyboard(false)
|
||||||
.title("Unlock your identity")
|
.title("Unlock your identity")
|
||||||
@@ -73,12 +72,13 @@ impl ImportIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
|
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
|
||||||
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
let error = cx.new(|_| None);
|
let error = cx.new(|_| None);
|
||||||
|
|
||||||
let subscriptions = vec![
|
let mut subscriptions = vec![
|
||||||
cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| {
|
cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| {
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
this.login(cx);
|
this.login(cx);
|
||||||
@@ -96,6 +96,14 @@ impl ImportIdentity {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
cx.subscribe_in(&nostr, window, |this, _nostr, event, _window, cx| {
|
||||||
|
if let StateEvent::Error(error) = event {
|
||||||
|
this.set_error(error.clone(), cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
mode,
|
mode,
|
||||||
key_input,
|
key_input,
|
||||||
@@ -188,6 +196,14 @@ impl ImportIdentity {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn extension(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
nostr.update(cx, |this, cx| {
|
||||||
|
this.connect_extension(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
self.loading = status;
|
self.loading = status;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -226,7 +242,6 @@ impl Render for ImportIdentity {
|
|||||||
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
|
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
|
||||||
const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time.";
|
const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time.";
|
||||||
|
|
||||||
let is_wasm = cfg!(target_arch = "wasm32");
|
|
||||||
let is_unlock = self.mode == Mode::Unlock;
|
let is_unlock = self.mode == Mode::Unlock;
|
||||||
|
|
||||||
let value = self.key_input.read(cx).value().trim().to_string();
|
let value = self.key_input.read(cx).value().trim().to_string();
|
||||||
@@ -305,7 +320,8 @@ impl Render for ImportIdentity {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(
|
.child({
|
||||||
|
let content = v_flex().gap_1().child(
|
||||||
Button::new("login")
|
Button::new("login")
|
||||||
.label(if is_unlock { "Unlock" } else { "Continue" })
|
.label(if is_unlock { "Unlock" } else { "Continue" })
|
||||||
.primary()
|
.primary()
|
||||||
@@ -314,17 +330,35 @@ impl Render for ImportIdentity {
|
|||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
this.login(cx);
|
this.login(cx);
|
||||||
})),
|
})),
|
||||||
)
|
);
|
||||||
.when(!is_wasm && !is_unlock, |this| {
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let content = content.when(!is_unlock, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("proxy")
|
Button::new("proxy")
|
||||||
.label("Connect via Web Extension (Experimental)")
|
.label("Connect via Web Extension")
|
||||||
.ghost()
|
.ghost()
|
||||||
.disabled(self.loading)
|
.disabled(self.loading)
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
this.proxy(cx);
|
this.proxy(cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let content = content.when(!is_unlock, |this| {
|
||||||
|
this.child(
|
||||||
|
Button::new("extension")
|
||||||
|
.label("Continue with Browser Extension")
|
||||||
|
.ghost()
|
||||||
|
.disabled(self.loading)
|
||||||
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
|
this.extension(cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
content
|
||||||
})
|
})
|
||||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||||
this.child(
|
this.child(
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
|||||||
.icon(IconName::ArrowRight)
|
.icon(IconName::ArrowRight)
|
||||||
.label("Join")
|
.label("Join")
|
||||||
.primary()
|
.primary()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.on_click({
|
.on_click({
|
||||||
let join = join.clone();
|
let join = join.clone();
|
||||||
move |event, window, cx| {
|
move |event, window, cx| {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use gpui::{
|
|||||||
Window, div, px, uniform_list,
|
Window, div, px, uniform_list,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::base::StyledExt as _;
|
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
|
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
|
||||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||||
@@ -205,6 +204,7 @@ impl NewChat {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ impl NewChat {
|
|||||||
|
|
||||||
Nav::new(start + offset)
|
Nav::new(start + offset)
|
||||||
.label(profile.name())
|
.label(profile.name())
|
||||||
.text_xs()
|
.text_sm()
|
||||||
.when(!AppSettings::get_hide_avatar(cx), |this| {
|
.when(!AppSettings::get_hide_avatar(cx), |this| {
|
||||||
this.prefix(
|
this.prefix(
|
||||||
Avatar::new(profile.avatar())
|
Avatar::new(profile.avatar())
|
||||||
@@ -369,15 +369,13 @@ impl NewChat {
|
|||||||
.p_0p5()
|
.p_0p5()
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.bg(cx.theme().secondary)
|
.bg(cx.theme().secondary)
|
||||||
.text_xs()
|
|
||||||
.font_medium()
|
|
||||||
.hover(|this| this.bg(cx.theme().primary))
|
.hover(|this| this.bg(cx.theme().primary))
|
||||||
.child(
|
.child(
|
||||||
Avatar::new(profile.avatar())
|
Avatar::new(profile.avatar())
|
||||||
.seed(profile.avatar_seed())
|
.seed(profile.avatar_seed())
|
||||||
.xsmall(),
|
.xsmall(),
|
||||||
)
|
)
|
||||||
.child(div().max_w_32().truncate().child(profile.name()))
|
.child(div().max_w_32().truncate().text_xs().child(profile.name()))
|
||||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||||
this.toggle_member(&public_key, cx);
|
this.toggle_member(&public_key, cx);
|
||||||
}))
|
}))
|
||||||
@@ -450,7 +448,6 @@ impl Render for NewChat {
|
|||||||
.icon(IconName::ArrowRight)
|
.icon(IconName::ArrowRight)
|
||||||
.label("Start")
|
.label("Start")
|
||||||
.primary()
|
.primary()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||||
if this.start_chat(window, cx) {
|
if this.start_chat(window, cx) {
|
||||||
window.close_dialog(cx);
|
window.close_dialog(cx);
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ use anyhow::Error;
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement,
|
App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement,
|
||||||
ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement as _,
|
ParentElement, Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
|
||||||
Styled, Subscription, Task, WeakEntity, Window, div, px, rems,
|
Subscription, Task, WeakEntity, Window, div, px, rems,
|
||||||
};
|
};
|
||||||
use gpui_base::{Popup, Select};
|
use gpui_base::{Popup, Select};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
|
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
|
||||||
use gpui_kit::component::form::field;
|
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants};
|
||||||
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||||
use gpui_kit::component::separator::Separator;
|
use gpui_kit::component::separator::Separator;
|
||||||
use gpui_kit::component::switch::Switch;
|
use gpui_kit::component::switch::Switch;
|
||||||
@@ -17,15 +18,15 @@ use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_
|
|||||||
use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry};
|
use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use state::upload;
|
use state::{pick_file, upload_file};
|
||||||
use ui::Avatar;
|
use ui::{Avatar, settings_row};
|
||||||
|
|
||||||
const DEFAULT_RELAYS: [&str; 2] = ["wss://relay.ditto.net", "wss://chat.wisp.talk"];
|
const DEFAULT_RELAYS: [&str; 2] = ["wss://relay.ditto.net", "wss://chat.wisp.talk"];
|
||||||
const PRIVATE_DESC: &str = "Only members can read group messages";
|
const PRIVATE_DESC: &str = "Only members can read group messages";
|
||||||
const CLOSED_DESC: &str = "Join requests are ignored (invite-only)";
|
const CLOSED_DESC: &str = "Join requests are ignored (invite-only)";
|
||||||
const RESTRICTED_DESC: &str = "Only members can post messages";
|
const RESTRICTED_DESC: &str = "Only members can post messages";
|
||||||
const HIDDEN_DESC: &str = "Hidden from non-members, not discoverable";
|
const HIDDEN_DESC: &str = "Hidden from non-members, not discoverable";
|
||||||
const LIST_PRIVATE_DESC: &str = "Kept encrypted in your group list so others can't see you joined";
|
const LIST_PRIVATE_DESC: &str = "Kept encrypted in your group list so others can't see";
|
||||||
|
|
||||||
pub fn open(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
pub fn open(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||||
open_with(dock, None, window, cx);
|
open_with(dock, None, window, cx);
|
||||||
@@ -296,16 +297,11 @@ impl NewGroup {
|
|||||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let server = AppSettings::get_file_server(cx);
|
let server = AppSettings::get_file_server(cx);
|
||||||
|
|
||||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
let picker = pick_file(cx);
|
||||||
files: true,
|
|
||||||
directories: false,
|
|
||||||
multiple: false,
|
|
||||||
prompt: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
// Selecting no file means the prompt was cancelled
|
// Selecting no file means the prompt was cancelled
|
||||||
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
let Some(picked) = picker.await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -313,7 +309,7 @@ impl NewGroup {
|
|||||||
this.set_uploading(true, cx);
|
this.set_uploading(true, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
match upload(server, path, cx).await {
|
match upload_file(server, picked, cx).await {
|
||||||
Ok(url) => {
|
Ok(url) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.picture = Some(url.to_string().into());
|
this.picture = Some(url.to_string().into());
|
||||||
@@ -461,71 +457,72 @@ impl NewGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_access(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_access(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let private = field().label("Private").description(PRIVATE_DESC).child(
|
GroupBox::new()
|
||||||
|
.fill()
|
||||||
|
.title("Access settings")
|
||||||
|
.title_style(group_box_title_style(cx))
|
||||||
|
.child(settings_row(
|
||||||
|
"Private",
|
||||||
|
PRIVATE_DESC,
|
||||||
Switch::new("private")
|
Switch::new("private")
|
||||||
.checked(self.private)
|
.checked(self.private)
|
||||||
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
this.private = *checked;
|
this.private = *checked;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
})),
|
||||||
);
|
cx,
|
||||||
|
))
|
||||||
let closed = field().label("Closed").description(CLOSED_DESC).child(
|
.child(settings_row(
|
||||||
|
"Closed",
|
||||||
|
CLOSED_DESC,
|
||||||
Switch::new("closed")
|
Switch::new("closed")
|
||||||
.checked(self.closed)
|
.checked(self.closed)
|
||||||
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
this.closed = *checked;
|
this.closed = *checked;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
})),
|
||||||
);
|
cx,
|
||||||
|
))
|
||||||
let restricted = field()
|
.child(settings_row(
|
||||||
.label("Restricted")
|
"Restricted",
|
||||||
.description(RESTRICTED_DESC)
|
RESTRICTED_DESC,
|
||||||
.child(
|
|
||||||
Switch::new("restricted")
|
Switch::new("restricted")
|
||||||
.checked(self.restricted)
|
.checked(self.restricted)
|
||||||
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
this.restricted = *checked;
|
this.restricted = *checked;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
})),
|
||||||
);
|
cx,
|
||||||
|
))
|
||||||
let hidden = field().label("Hidden").description(HIDDEN_DESC).child(
|
.child(settings_row(
|
||||||
|
"Hidden",
|
||||||
|
HIDDEN_DESC,
|
||||||
Switch::new("hidden")
|
Switch::new("hidden")
|
||||||
.checked(self.hidden)
|
.checked(self.hidden)
|
||||||
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
this.hidden = *checked;
|
this.hidden = *checked;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
})),
|
||||||
);
|
cx,
|
||||||
|
))
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.child(field_label("Access settings", cx))
|
|
||||||
.child(switch_row(private, cx))
|
|
||||||
.child(switch_row(closed, cx))
|
|
||||||
.child(switch_row(restricted, cx))
|
|
||||||
.child(switch_row(hidden, cx))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_list_setting(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_list_setting(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let list_private = field()
|
GroupBox::new()
|
||||||
.label("Private on my list")
|
.fill()
|
||||||
.description(LIST_PRIVATE_DESC)
|
.title("List")
|
||||||
.child(
|
.title_style(group_box_title_style(cx))
|
||||||
|
.child(settings_row(
|
||||||
|
"Private on my list",
|
||||||
|
LIST_PRIVATE_DESC,
|
||||||
Switch::new("list-private")
|
Switch::new("list-private")
|
||||||
.checked(self.list_private)
|
.checked(self.list_private)
|
||||||
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
this.list_private = *checked;
|
this.list_private = *checked;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
})),
|
||||||
);
|
cx,
|
||||||
|
))
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.child(field_label("List", cx))
|
|
||||||
.child(switch_row(list_private, cx))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_error(
|
fn render_error(
|
||||||
@@ -641,7 +638,6 @@ impl Render for NewGroup {
|
|||||||
"Create Group"
|
"Create Group"
|
||||||
})
|
})
|
||||||
.primary()
|
.primary()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.loading(self.creating)
|
.loading(self.creating)
|
||||||
.disabled(self.creating || self.uploading)
|
.disabled(self.creating || self.uploading)
|
||||||
.on_click(cx.listener(|this, _event, _window, cx| this.create(cx))),
|
.on_click(cx.listener(|this, _event, _window, cx| this.create(cx))),
|
||||||
@@ -658,17 +654,16 @@ fn field_label(text: &'static str, cx: &App) -> Div {
|
|||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.child(text)
|
.child(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn switch_row(switch: impl IntoElement, cx: &App) -> Div {
|
/// Match [`field_label`]'s design for GroupBox titles.
|
||||||
div()
|
fn group_box_title_style(cx: &App) -> StyleRefinement {
|
||||||
.w_full()
|
StyleRefinement::default()
|
||||||
.p_2()
|
.text_xs()
|
||||||
.rounded(cx.theme().radius)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.bg(cx.theme().secondary)
|
.font_semibold()
|
||||||
.child(switch)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn relay_label(relay: &RelayUrl) -> SharedString {
|
fn relay_label(relay: &RelayUrl) -> SharedString {
|
||||||
|
|||||||
@@ -4,20 +4,21 @@ use anyhow::Error;
|
|||||||
use chat::{ChatRegistry, Room, RoomKind};
|
use chat::{ChatRegistry, Room, RoomKind};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement,
|
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||||
PathPromptOptions, Render, SharedString, Styled, Task, Window, div, retain_all,
|
Styled, Task, Window, div, retain_all,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
|
use gpui_kit::component::clipboard::Clipboard;
|
||||||
use gpui_kit::component::dock::DockPlacement;
|
use gpui_kit::component::dock::DockPlacement;
|
||||||
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
|
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
|
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use state::{NostrRegistry, upload};
|
use state::{NostrRegistry, pick_file, upload_file};
|
||||||
use ui::Avatar;
|
use ui::Avatar;
|
||||||
|
|
||||||
use crate::Workspace;
|
use crate::Workspace;
|
||||||
@@ -48,8 +49,6 @@ struct ProfileDialog {
|
|||||||
website_input: Entity<InputState>,
|
website_input: Entity<InputState>,
|
||||||
/// Uploading state
|
/// Uploading state
|
||||||
uploading: bool,
|
uploading: bool,
|
||||||
/// Copied states
|
|
||||||
copied: bool,
|
|
||||||
/// Updating state
|
/// Updating state
|
||||||
updating: bool,
|
updating: bool,
|
||||||
/// Tasks
|
/// Tasks
|
||||||
@@ -91,7 +90,6 @@ impl ProfileDialog {
|
|||||||
bio_input,
|
bio_input,
|
||||||
website_input,
|
website_input,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
copied: false,
|
|
||||||
updating: false,
|
updating: false,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
}
|
}
|
||||||
@@ -140,35 +138,6 @@ impl ProfileDialog {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let item = ClipboardItem::new_string(value);
|
|
||||||
cx.write_to_clipboard(item);
|
|
||||||
|
|
||||||
self.set_copied(true, window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.copied = status;
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
if status {
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
|
||||||
|
|
||||||
// Reset the copied state after a delay
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_copied(false, window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
self.uploading = status;
|
self.uploading = status;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -178,17 +147,12 @@ impl ProfileDialog {
|
|||||||
// Get the user's configured blossom server
|
// Get the user's configured blossom server
|
||||||
let server = AppSettings::get_file_server(cx);
|
let server = AppSettings::get_file_server(cx);
|
||||||
|
|
||||||
// Ask user for file upload
|
// Ask the user for an image to upload
|
||||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
let picker = pick_file(cx);
|
||||||
files: true,
|
|
||||||
directories: false,
|
|
||||||
multiple: false,
|
|
||||||
prompt: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
// Selecting no file means the prompt was cancelled
|
// Selecting no file means the prompt was cancelled
|
||||||
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
let Some(picked) = picker.await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -197,7 +161,7 @@ impl ProfileDialog {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Upload via blossom client
|
// Upload via blossom client
|
||||||
match upload(server, path, cx).await {
|
match upload_file(server, picked, cx).await {
|
||||||
Ok(url) => {
|
Ok(url) => {
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.avatar_input.update(cx, |this, cx| {
|
this.avatar_input.update(cx, |this, cx| {
|
||||||
@@ -372,7 +336,8 @@ impl Render for ProfileDialog {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.bg(cx.theme().sidebar)
|
.rounded(cx.theme().radius_lg)
|
||||||
|
.bg(cx.theme().muted)
|
||||||
.child(Avatar::new(avatar).seed(profile.avatar_seed()).large())
|
.child(Avatar::new(avatar).seed(profile.avatar_seed()).large())
|
||||||
.when(self.editing, |this| {
|
.when(self.editing, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
@@ -397,45 +362,33 @@ impl Render for ProfileDialog {
|
|||||||
.justify_center()
|
.justify_center()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.bg(cx.theme().sidebar)
|
.bg(cx.theme().muted)
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.child(shorten_pkey)
|
.child(shorten_pkey)
|
||||||
.child(
|
.child(Clipboard::new("copy").value_fn({
|
||||||
Button::new("copy")
|
let public_key = self.public_key;
|
||||||
.icon({
|
move |_, _| public_key.to_bech32().unwrap_or_default().into()
|
||||||
if self.copied {
|
|
||||||
IconName::CircleCheck
|
|
||||||
} else {
|
|
||||||
IconName::Copy
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.xsmall()
|
|
||||||
.secondary()
|
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
|
||||||
let pk = this.public_key.to_bech32().unwrap();
|
|
||||||
this.copy(pk, window, cx)
|
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.when(self.editing, |this| {
|
.when(self.editing, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.child(label("Name", cx))
|
.child(label("Name", cx))
|
||||||
.child(Input::new(&self.name_input).small()),
|
.child(Input::new(&self.name_input)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.child(label("About", cx))
|
.child(label("About", cx))
|
||||||
.child(Textarea::new(&self.bio_input).small()),
|
.child(Textarea::new(&self.bio_input)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.child(label("Website", cx))
|
.child(label("Website", cx))
|
||||||
.child(Input::new(&self.website_input).small()),
|
.child(Input::new(&self.website_input)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(!self.editing, |this| {
|
.when(!self.editing, |this| {
|
||||||
@@ -486,7 +439,6 @@ impl Render for ProfileDialog {
|
|||||||
.icon(IconName::SquarePen)
|
.icon(IconName::SquarePen)
|
||||||
.label("Update")
|
.label("Update")
|
||||||
.primary()
|
.primary()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.toggle_edit(window, cx);
|
this.toggle_edit(window, cx);
|
||||||
})),
|
})),
|
||||||
@@ -498,7 +450,6 @@ impl Render for ProfileDialog {
|
|||||||
.icon(IconName::MessageSquare)
|
.icon(IconName::MessageSquare)
|
||||||
.label("Message")
|
.label("Message")
|
||||||
.primary()
|
.primary()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.message(window, cx);
|
this.message(window, cx);
|
||||||
})),
|
})),
|
||||||
@@ -519,7 +470,6 @@ impl Render for ProfileDialog {
|
|||||||
.icon(IconName::CircleCheck)
|
.icon(IconName::CircleCheck)
|
||||||
.label("Save")
|
.label("Save")
|
||||||
.primary()
|
.primary()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.loading(self.updating)
|
.loading(self.updating)
|
||||||
.disabled(self.updating)
|
.disabled(self.updating)
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
@@ -535,7 +485,7 @@ fn label(label: &'static str, cx: &App) -> Div {
|
|||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.child(label)
|
.child(label)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +501,7 @@ fn field(
|
|||||||
};
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1p5()
|
.gap_1()
|
||||||
.child(label(text, cx))
|
.child(label(text, cx))
|
||||||
.child(div().text_xs().text_color(color).child(content))
|
.child(div().text_sm().text_color(color).child(content))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
use gpui_kit::assets::IconName;
|
|
||||||
use gpui_kit::base::StyledExt as _;
|
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
|
||||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
|
||||||
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
|
||||||
use gpui_kit::component::scroll::ScrollableElement;
|
|
||||||
use gpui_kit::component::tab::{Tab, TabBar};
|
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
@@ -15,6 +7,14 @@ use gpui::{
|
|||||||
ParentElement, Render, SharedString, Stateful, Styled, Subscription, Task, TextAlign,
|
ParentElement, Render, SharedString, Stateful, Styled, Subscription, Task, TextAlign,
|
||||||
WeakEntity, Window, div, rems,
|
WeakEntity, Window, div, rems,
|
||||||
};
|
};
|
||||||
|
use gpui_kit::assets::IconName;
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
|
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||||
|
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
||||||
|
use gpui_kit::component::scroll::ScrollableElement;
|
||||||
|
use gpui_kit::component::tab::{Tab, TabBar};
|
||||||
|
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -119,7 +119,7 @@ impl RelayManager {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(Input::new(&self.gossip.input).small().cleanable(true))
|
.child(Input::new(&self.gossip.input).cleanable(true))
|
||||||
.child(
|
.child(
|
||||||
Button::new("metadata")
|
Button::new("metadata")
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
@@ -208,7 +208,7 @@ impl RelayManager {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(Input::new(&self.inbox.input).small().cleanable(true))
|
.child(Input::new(&self.inbox.input).cleanable(true))
|
||||||
.child(
|
.child(
|
||||||
Button::new("add-inbox")
|
Button::new("add-inbox")
|
||||||
.icon(IconName::Plus)
|
.icon(IconName::Plus)
|
||||||
@@ -264,7 +264,7 @@ impl RelayManager {
|
|||||||
|
|
||||||
fn render_tab_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_tab_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
TabBar::new("relays-tabs")
|
TabBar::new("relays-tabs")
|
||||||
.segmented()
|
.pill()
|
||||||
.selected_index(self.active_tab)
|
.selected_index(self.active_tab)
|
||||||
.on_click(cx.listener(|this, ix: &usize, _window, cx| {
|
.on_click(cx.listener(|this, ix: &usize, _window, cx| {
|
||||||
this.active_tab = *ix;
|
this.active_tab = *ix;
|
||||||
@@ -286,7 +286,6 @@ impl RelayManager {
|
|||||||
.icon(IconName::ArrowRight)
|
.icon(IconName::ArrowRight)
|
||||||
.label("Update")
|
.label("Update")
|
||||||
.primary()
|
.primary()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.loading(updating)
|
.loading(updating)
|
||||||
.disabled(updating)
|
.disabled(updating)
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ impl Render for RestoreEncryption {
|
|||||||
.icon(IconName::ArrowRight)
|
.icon(IconName::ArrowRight)
|
||||||
.label("Restore")
|
.label("Restore")
|
||||||
.primary()
|
.primary()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
this.restore(window, cx);
|
this.restore(window, cx);
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use gpui::{
|
|||||||
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
|
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::spinner::Spinner;
|
use gpui_kit::component::spinner::Spinner;
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
||||||
@@ -376,7 +377,7 @@ impl Render for Screening {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.line_height(relative(1.25))
|
.line_height(relative(1.25))
|
||||||
.child(profile.name()),
|
.child(profile.name()),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chat_ui::ToggleDictation;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui::PathPromptOptions;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui::Task;
|
||||||
use gpui::http_client::Url;
|
use gpui::http_client::Url;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, IntoElement, ParentElement, PathPromptOptions, Render,
|
AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled,
|
||||||
SharedString, Styled, Window, div,
|
Subscription, Window, div,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::base::StyledExt as _;
|
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::form::field;
|
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
|
||||||
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants};
|
use gpui_kit::component::kbd::Kbd;
|
||||||
use gpui_kit::component::input::{Input, InputState};
|
|
||||||
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui_kit::component::progress::Progress;
|
||||||
|
use gpui_kit::component::separator::Separator;
|
||||||
use gpui_kit::component::switch::Switch;
|
use gpui_kit::component::switch::Switch;
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use settings::AppSettings;
|
use settings::{AppSettings, AppearanceMode};
|
||||||
use settings::AppearanceMode;
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use speech::model::{self as speech_model};
|
||||||
|
use ui::{settings_block, settings_row};
|
||||||
|
|
||||||
|
/// Bounds for the theme number inputs
|
||||||
|
const FONT_SIZE_RANGE: (f32, f32) = (10., 32.);
|
||||||
|
const RADIUS_RANGE: (f32, f32) = (0., 24.);
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
||||||
cx.new(|cx| Preferences::new(window, cx))
|
cx.new(|cx| Preferences::new(window, cx))
|
||||||
@@ -22,6 +37,13 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
|||||||
|
|
||||||
pub struct Preferences {
|
pub struct Preferences {
|
||||||
file_input: Entity<InputState>,
|
file_input: Entity<InputState>,
|
||||||
|
font_size_input: Entity<InputState>,
|
||||||
|
radius_input: Entity<InputState>,
|
||||||
|
radius_lg_input: Entity<InputState>,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
_speech_poll: Task<()>,
|
||||||
|
/// Keeps the control subscriptions alive while the dialog is open
|
||||||
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Preferences {
|
impl Preferences {
|
||||||
@@ -33,7 +55,120 @@ impl Preferences {
|
|||||||
.placeholder("https://myblossom.com")
|
.placeholder("https://myblossom.com")
|
||||||
});
|
});
|
||||||
|
|
||||||
Self { file_input }
|
let theme = AppSettings::get_theme(cx);
|
||||||
|
let font_size_input = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.default_value(theme.font_size.to_string())
|
||||||
|
.step(1.)
|
||||||
|
.min(FONT_SIZE_RANGE.0 as f64)
|
||||||
|
.max(FONT_SIZE_RANGE.1 as f64)
|
||||||
|
});
|
||||||
|
let radius_input = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.default_value(
|
||||||
|
theme
|
||||||
|
.radius
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.placeholder("System")
|
||||||
|
.step(1.)
|
||||||
|
.min(RADIUS_RANGE.0 as f64)
|
||||||
|
.max(RADIUS_RANGE.1 as f64)
|
||||||
|
});
|
||||||
|
let radius_lg_input = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.default_value(
|
||||||
|
theme
|
||||||
|
.radius_lg
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.placeholder("System")
|
||||||
|
.step(1.)
|
||||||
|
.min(RADIUS_RANGE.0 as f64)
|
||||||
|
.max(RADIUS_RANGE.1 as f64)
|
||||||
|
});
|
||||||
|
|
||||||
|
let subscriptions = vec![
|
||||||
|
Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| {
|
||||||
|
if let Some(font_size) = value {
|
||||||
|
AppSettings::update_theme(|theme| theme.font_size = font_size, cx);
|
||||||
|
AppSettings::apply_theme_style(cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Self::wire_number_input(&radius_input, RADIUS_RANGE, cx, |value, cx| {
|
||||||
|
AppSettings::update_theme(|theme| theme.radius = value, cx);
|
||||||
|
AppSettings::apply_theme_style(cx);
|
||||||
|
}),
|
||||||
|
Self::wire_number_input(&radius_lg_input, RADIUS_RANGE, cx, |value, cx| {
|
||||||
|
AppSettings::update_theme(|theme| theme.radius_lg = value, cx);
|
||||||
|
AppSettings::apply_theme_style(cx);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let _speech_poll = cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let timer = Duration::from_millis(500);
|
||||||
|
let mut last = speech_model::status();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
cx.background_executor().timer(timer).await;
|
||||||
|
|
||||||
|
let status = speech_model::status();
|
||||||
|
let error = speech_model::take_error();
|
||||||
|
|
||||||
|
if status == last {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
last = status;
|
||||||
|
|
||||||
|
this.update_in(cx, |_, window, cx| {
|
||||||
|
if let Some(error) = error {
|
||||||
|
window.push_notification(Notification::error(error).autohide(false), cx);
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
file_input,
|
||||||
|
font_size_input,
|
||||||
|
radius_input,
|
||||||
|
radius_lg_input,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
_speech_poll,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a theme number as it changes; an empty value stores `None`
|
||||||
|
fn wire_number_input(
|
||||||
|
state: &Entity<InputState>,
|
||||||
|
range: (f32, f32),
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
persist: impl Fn(Option<f32>, &mut App) + 'static,
|
||||||
|
) -> Subscription {
|
||||||
|
let (min, max) = range;
|
||||||
|
let state = state.clone();
|
||||||
|
|
||||||
|
cx.subscribe(&state, move |_, state, event: &InputEvent, cx| {
|
||||||
|
if !matches!(event, InputEvent::Change) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = state
|
||||||
|
.read(cx)
|
||||||
|
.value()
|
||||||
|
.trim()
|
||||||
|
.parse::<f32>()
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.clamp(min, max));
|
||||||
|
persist(value, cx);
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the file server (blossom) URL
|
/// Update the file server (blossom) URL
|
||||||
@@ -59,7 +194,18 @@ impl Preferences {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Ask the user for an image to show behind chat and group panels
|
/// Ask the user for an image to show behind chat and group panels
|
||||||
fn choose_background(cx: &mut App) {
|
fn choose_background(
|
||||||
|
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))] window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let note = Notification::warning("Background images are not supported on web yet.");
|
||||||
|
window.push_notification(note, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
let task = cx.prompt_for_paths(PathPromptOptions {
|
let task = cx.prompt_for_paths(PathPromptOptions {
|
||||||
files: true,
|
files: true,
|
||||||
directories: false,
|
directories: false,
|
||||||
@@ -85,20 +231,121 @@ impl Preferences {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_speech(
|
||||||
|
&mut self,
|
||||||
|
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))] cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
return div().into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let size = speech_model::total_size() / 1_000_000;
|
||||||
|
let status = speech_model::status();
|
||||||
|
|
||||||
|
let (description, control) = match &status {
|
||||||
|
speech_model::Status::NotInstalled => (
|
||||||
|
format!("{size} MB download"),
|
||||||
|
Button::new("download-model")
|
||||||
|
.label("Download")
|
||||||
|
.secondary()
|
||||||
|
.small()
|
||||||
|
.on_click(|_, _window, cx| {
|
||||||
|
speech_model::start_download();
|
||||||
|
cx.refresh_windows();
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
),
|
||||||
|
speech_model::Status::Downloading { downloaded, total } => (
|
||||||
|
format!(
|
||||||
|
"Downloading · {} of {} MB",
|
||||||
|
downloaded / 1_000_000,
|
||||||
|
total / 1_000_000
|
||||||
|
),
|
||||||
|
Button::new("cancel-download")
|
||||||
|
.label("Cancel")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(|_, _window, cx| {
|
||||||
|
speech_model::cancel_download();
|
||||||
|
cx.refresh_windows();
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
),
|
||||||
|
speech_model::Status::Installed => (
|
||||||
|
format!("Parakeet v3 · {size} MB"),
|
||||||
|
Button::new("remove-model")
|
||||||
|
.label("Remove")
|
||||||
|
.secondary()
|
||||||
|
.small()
|
||||||
|
.on_click(|_, _window, cx| {
|
||||||
|
speech_model::delete();
|
||||||
|
cx.refresh_windows();
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut block = v_flex().w_full().gap_3().child(settings_row(
|
||||||
|
"Speech model",
|
||||||
|
description,
|
||||||
|
control,
|
||||||
|
cx,
|
||||||
|
));
|
||||||
|
|
||||||
|
if let speech_model::Status::Downloading { downloaded, total } = &status {
|
||||||
|
let (downloaded, total) = (*downloaded, *total);
|
||||||
|
|
||||||
|
block = block.child(
|
||||||
|
Progress::new("speech-model-download")
|
||||||
|
.value(downloaded as f32 / total.max(1) as f32 * 100.)
|
||||||
|
.small(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
block.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the keystroke actually bound to the dictation action at startup
|
||||||
|
fn render_dictation_shortcut(
|
||||||
|
&self,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
let control = match Kbd::global_binding_for_action(&ToggleDictation, window) {
|
||||||
|
Some(kbd) => kbd.into_any_element(),
|
||||||
|
None => div().into_any_element(),
|
||||||
|
};
|
||||||
|
|
||||||
|
settings_row("Shortcut", "Press to start or stop dictation", control, cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Render for Preferences {
|
impl Render for Preferences {
|
||||||
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 {
|
||||||
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
|
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
|
||||||
const WOT: &str = "Treat people followed by your contacts as known senders.";
|
const WOT: &str = "Treat people followed by your contacts as known senders.";
|
||||||
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
||||||
const MODE: &str = "Follow the OS, or always use light or dark.";
|
const APPEARANCE: &str =
|
||||||
|
"Choose whether the app follows the system theme or uses a light/dark theme.";
|
||||||
|
const FONT_SIZE: &str = "The base font size for the interface.";
|
||||||
|
const RADIUS: &str = "Corner radius for UI elements. Empty follows the OS theme.";
|
||||||
|
const RADIUS_LG: &str =
|
||||||
|
"Corner radius for large elements like dialogs. Empty follows the OS theme.";
|
||||||
|
const FOCUS_RING: &str = "Draw a ring around focused controls.";
|
||||||
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
||||||
const BACKGROUND: &str = "Show an image behind chat and group panels.";
|
const BACKGROUND: &str = "Show an image behind chat and group panels.";
|
||||||
|
const MEDIA: &str = "Server for media attachments. Only blossom services are supported.";
|
||||||
|
|
||||||
let screening = AppSettings::get_screening(cx);
|
let screening = AppSettings::get_screening(cx);
|
||||||
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
|
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
let nip4e = AppSettings::get_nip4e(cx);
|
let nip4e = AppSettings::get_nip4e(cx);
|
||||||
let appearance_mode = AppSettings::get_appearance_mode(cx);
|
let appearance_mode = AppSettings::get_appearance_mode(cx);
|
||||||
|
let theme = AppSettings::get_theme(cx);
|
||||||
let panel_background = AppSettings::get_panel_background(cx);
|
let panel_background = AppSettings::get_panel_background(cx);
|
||||||
let panel_background_path = AppSettings::get_panel_background_path(cx);
|
let panel_background_path = AppSettings::get_panel_background_path(cx);
|
||||||
|
|
||||||
@@ -108,56 +355,48 @@ impl Render for Preferences {
|
|||||||
};
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.mt_2()
|
||||||
.gap_4()
|
.gap_4()
|
||||||
|
.w_full()
|
||||||
.child(
|
.child(
|
||||||
GroupBox::new()
|
v_flex()
|
||||||
.id("general")
|
.w_full()
|
||||||
.title("General")
|
.gap_3()
|
||||||
.fill()
|
.child(settings_row(
|
||||||
.child(field().label("Screening").description(SCREENING).child(
|
"Screening",
|
||||||
|
SCREENING,
|
||||||
Switch::new("screening").checked(screening).on_click(
|
Switch::new("screening").checked(screening).on_click(
|
||||||
move |_, _window, cx| {
|
move |_, _window, cx| {
|
||||||
AppSettings::update_screening(!screening, cx);
|
AppSettings::update_screening(!screening, cx);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
cx,
|
||||||
))
|
))
|
||||||
.child(
|
.child(settings_row(
|
||||||
field().label("Web of Trust").description(WOT).child(
|
"Web of Trust",
|
||||||
|
WOT,
|
||||||
Switch::new("wot-auto-trust")
|
Switch::new("wot-auto-trust")
|
||||||
.checked(wot_auto_trust)
|
.checked(wot_auto_trust)
|
||||||
.on_click(move |_, _window, cx| {
|
.on_click(move |_, _window, cx| {
|
||||||
AppSettings::update_wot_auto_trust(!wot_auto_trust, cx);
|
AppSettings::update_wot_auto_trust(!wot_auto_trust, cx);
|
||||||
}),
|
}),
|
||||||
),
|
cx,
|
||||||
)
|
))
|
||||||
.child(field().label("Hide user avatar").description(AVATAR).child(
|
.child(settings_row(
|
||||||
|
"Hide user avatar",
|
||||||
|
AVATAR,
|
||||||
Switch::new("avatar").checked(hide_avatar).on_click(
|
Switch::new("avatar").checked(hide_avatar).on_click(
|
||||||
move |_, _window, cx| {
|
move |_, _window, cx| {
|
||||||
AppSettings::update_hide_avatar(!hide_avatar, cx);
|
AppSettings::update_hide_avatar(!hide_avatar, cx);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
cx,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(Separator::horizontal())
|
||||||
GroupBox::new()
|
.child(settings_row(
|
||||||
.id("appearance")
|
"Appearance",
|
||||||
.title("Appearance")
|
APPEARANCE,
|
||||||
.fill()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_3()
|
|
||||||
.justify_between()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.child(div().text_xs().child(SharedString::from("Mode")))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().muted_foreground)
|
|
||||||
.child(SharedString::from(MODE)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("appearance-mode")
|
Button::new("appearance-mode")
|
||||||
.label(appearance_mode.label())
|
.label(appearance_mode.label())
|
||||||
.ghost()
|
.ghost()
|
||||||
@@ -171,47 +410,67 @@ impl Render for Preferences {
|
|||||||
))
|
))
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
),
|
cx,
|
||||||
),
|
))
|
||||||
|
.child(Separator::horizontal())
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.w_full()
|
||||||
|
.gap_3()
|
||||||
|
.child(settings_row(
|
||||||
|
"Font Size",
|
||||||
|
FONT_SIZE,
|
||||||
|
NumberInput::new(&self.font_size_input).w_24(),
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
.child(settings_row(
|
||||||
|
"Corner Radius",
|
||||||
|
RADIUS,
|
||||||
|
NumberInput::new(&self.radius_input).w_24(),
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
.child(settings_row(
|
||||||
|
"Large Corner Radius",
|
||||||
|
RADIUS_LG,
|
||||||
|
NumberInput::new(&self.radius_lg_input).w_24(),
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
.child(settings_row(
|
||||||
|
"Focus Ring",
|
||||||
|
FOCUS_RING,
|
||||||
|
Switch::new("focus-ring")
|
||||||
|
.checked(theme.focus_ring)
|
||||||
|
.on_click(move |_, _window, cx| {
|
||||||
|
AppSettings::update_theme(
|
||||||
|
|theme| theme.focus_ring = !theme.focus_ring,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
AppSettings::apply_theme_style(cx);
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
|
.child(Separator::horizontal())
|
||||||
.child(
|
.child(
|
||||||
GroupBox::new()
|
v_flex()
|
||||||
.id("background")
|
.w_full()
|
||||||
.title("Background")
|
.gap_3()
|
||||||
.fill()
|
.child(settings_row(
|
||||||
.child(
|
"Panel background",
|
||||||
field()
|
BACKGROUND,
|
||||||
.label("Panel background")
|
|
||||||
.description(BACKGROUND)
|
|
||||||
.child(
|
|
||||||
Switch::new("panel-background")
|
Switch::new("panel-background")
|
||||||
.checked(panel_background)
|
.checked(panel_background)
|
||||||
.on_click(move |_, _window, cx| {
|
.on_click(move |_, _window, cx| {
|
||||||
AppSettings::update_panel_background(!panel_background, cx);
|
AppSettings::update_panel_background(!panel_background, cx);
|
||||||
cx.refresh_windows();
|
cx.refresh_windows();
|
||||||
}),
|
}),
|
||||||
),
|
cx,
|
||||||
)
|
))
|
||||||
.child(
|
.child(settings_block(
|
||||||
|
"Image",
|
||||||
|
background_label,
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_3()
|
.justify_end()
|
||||||
.justify_between()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.flex_1()
|
|
||||||
.min_w_0()
|
|
||||||
.child(div().text_xs().child(SharedString::from("Image")))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().muted_foreground)
|
|
||||||
.truncate()
|
|
||||||
.child(SharedString::from(background_label)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(
|
.child(
|
||||||
Button::new("choose-background")
|
Button::new("choose-background")
|
||||||
@@ -219,8 +478,8 @@ impl Render for Preferences {
|
|||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.disabled(!panel_background)
|
.disabled(!panel_background)
|
||||||
.on_click(|_, _window, cx| {
|
.on_click(|_, window, cx| {
|
||||||
Self::choose_background(cx);
|
Self::choose_background(window, cx);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -228,46 +487,36 @@ impl Render for Preferences {
|
|||||||
.label("Reset")
|
.label("Reset")
|
||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.disabled(
|
.disabled(!panel_background || panel_background_path.is_none())
|
||||||
!panel_background
|
|
||||||
|| panel_background_path.is_none(),
|
|
||||||
)
|
|
||||||
.on_click(|_, _window, cx| {
|
.on_click(|_, _window, cx| {
|
||||||
AppSettings::update_panel_background_path(None, cx);
|
AppSettings::update_panel_background_path(None, cx);
|
||||||
cx.refresh_windows();
|
cx.refresh_windows();
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
cx,
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
GroupBox::new()
|
|
||||||
.id("experiments")
|
|
||||||
.title("Experiments")
|
|
||||||
.fill()
|
|
||||||
.child(
|
|
||||||
field()
|
|
||||||
.label("Decoupling Encryption Key")
|
|
||||||
.description(NIP4E)
|
|
||||||
.child(Switch::new("nip4e").checked(nip4e).on_click(
|
|
||||||
move |_, _window, cx| {
|
|
||||||
AppSettings::update_nip4e(!nip4e, cx);
|
|
||||||
},
|
|
||||||
)),
|
)),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.child(
|
.child(Separator::horizontal())
|
||||||
GroupBox::new()
|
.child(settings_row(
|
||||||
.id("media")
|
"Decoupling Encryption Key",
|
||||||
.title("Media Upload Service")
|
NIP4E,
|
||||||
.fill()
|
Switch::new("nip4e")
|
||||||
.child(
|
.checked(nip4e)
|
||||||
v_flex()
|
.on_click(move |_, _window, cx| {
|
||||||
.gap_0p5()
|
AppSettings::update_nip4e(!nip4e, cx);
|
||||||
.child(
|
}),
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
.child(Separator::horizontal())
|
||||||
|
.child(self.render_speech(cx))
|
||||||
|
.child(self.render_dictation_shortcut(window, cx))
|
||||||
|
.child(Separator::horizontal())
|
||||||
|
.child(settings_block(
|
||||||
|
"Media Upload Service",
|
||||||
|
MEDIA,
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(Input::new(&self.file_input).small())
|
.child(div().flex_1().child(Input::new(&self.file_input)))
|
||||||
.child(
|
.child(
|
||||||
Button::new("update-file-server")
|
Button::new("update-file-server")
|
||||||
.icon(IconName::Check)
|
.icon(IconName::Check)
|
||||||
@@ -277,16 +526,7 @@ impl Render for Preferences {
|
|||||||
this.update_file_server(window, cx)
|
this.update_file_server(window, cx)
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
)
|
cx,
|
||||||
.child(
|
))
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.font_medium()
|
|
||||||
.italic()
|
|
||||||
.text_color(cx.theme().muted_foreground)
|
|
||||||
.child(SharedString::from("Only support blossom service")),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ impl Render for DragPanelPreview {
|
|||||||
.cursor_grab()
|
.cursor_grab()
|
||||||
.p_1()
|
.p_1()
|
||||||
.whitespace_nowrap()
|
.whitespace_nowrap()
|
||||||
|
.font_family(cx.theme().font_family.clone())
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.rounded(cx.theme().radius_lg)
|
.rounded(cx.theme().radius_lg)
|
||||||
@@ -358,7 +359,7 @@ impl CoopTabGroupSkin {
|
|||||||
this
|
this
|
||||||
} else {
|
} else {
|
||||||
this.text_color(cx.theme().tab_foreground)
|
this.text_color(cx.theme().tab_foreground)
|
||||||
.bg(cx.theme().tab)
|
.bg(cx.theme().list_hover)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.selected(active)
|
.selected(active)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use ::settings::{AppSettings, AppearanceMode};
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
use chat::{ChatEvent, ChatRegistry};
|
use chat::{ChatEvent, ChatRegistry};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use common::download_dir;
|
use common::download_dir;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
@@ -11,7 +14,7 @@ use gpui::{
|
|||||||
use gpui_kit::component::button::Button;
|
use gpui_kit::component::button::Button;
|
||||||
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||||
use gpui_kit::component::notification::{Notification, NotificationType};
|
use gpui_kit::component::notification::{Notification, NotificationType};
|
||||||
use gpui_kit::component::{ActiveTheme, WindowExt, v_flex};
|
use gpui_kit::component::{ActiveTheme, Theme, WindowExt, v_flex};
|
||||||
use nip4e::{DeviceEvent, DeviceRegistry};
|
use nip4e::{DeviceEvent, DeviceRegistry};
|
||||||
use nip29::{GroupId, GroupKey};
|
use nip29::{GroupId, GroupKey};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -88,13 +91,9 @@ impl Workspace {
|
|||||||
let workspace = cx.entity().downgrade();
|
let workspace = cx.entity().downgrade();
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
// Follow OS appearance changes only when the user has not pinned a
|
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
|
||||||
// mode; the native-theme watcher drives runtime changes either way.
|
|
||||||
// The `::settings` path is the external crate; the crate-local
|
|
||||||
// `settings` module is the preferences dialog.
|
|
||||||
if ::settings::AppSettings::get_appearance_mode(cx) == ::settings::AppearanceMode::System {
|
|
||||||
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
|
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
|
||||||
gpui_kit::component::Theme::sync_system_appearance(Some(window), cx);
|
Theme::sync_system_appearance(Some(window), cx);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +277,7 @@ impl Workspace {
|
|||||||
let view = settings::init(window, cx);
|
let view = settings::init(window, cx);
|
||||||
|
|
||||||
window.open_dialog(cx, move |this, _window, _cx| {
|
window.open_dialog(cx, move |this, _window, _cx| {
|
||||||
this.width(px(520.))
|
this.width(px(650.))
|
||||||
.close_button(true)
|
.close_button(true)
|
||||||
.pb_2()
|
.pb_2()
|
||||||
.title("Preferences")
|
.title("Preferences")
|
||||||
@@ -340,7 +339,11 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
Command::BackupEncryption => {
|
Command::BackupEncryption => {
|
||||||
let device = DeviceRegistry::global(cx).downgrade();
|
let device = DeviceRegistry::global(cx).downgrade();
|
||||||
let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt"));
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let save_dialog =
|
||||||
|
cx.prompt_for_new_path(download_dir(), Some("encryption.txt"));
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
||||||
// Get the output path from the save dialog
|
// Get the output path from the save dialog
|
||||||
@@ -349,7 +352,8 @@ impl Workspace {
|
|||||||
Ok(Ok(None)) | Err(_) => return Ok(()),
|
Ok(Ok(None)) | Err(_) => return Ok(()),
|
||||||
Ok(Err(error)) => {
|
Ok(Err(error)) => {
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
let message = format!("Failed to pick save location: {error:#}");
|
let message =
|
||||||
|
format!("Failed to pick save location: {error:#}");
|
||||||
let note = Notification::error(message).autohide(false);
|
let note = Notification::error(message).autohide(false);
|
||||||
window.push_notification(note, cx);
|
window.push_notification(note, cx);
|
||||||
})?;
|
})?;
|
||||||
@@ -358,8 +362,8 @@ impl Workspace {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get the backup task
|
// Get the backup task
|
||||||
let backup =
|
let backup = device
|
||||||
device.read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?;
|
.read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?;
|
||||||
|
|
||||||
// Run the backup task
|
// Run the backup task
|
||||||
backup.await?;
|
backup.await?;
|
||||||
@@ -372,10 +376,30 @@ impl Workspace {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
||||||
|
let backup = device.read_with(cx, |this, cx| this.backup_bytes(cx))?;
|
||||||
|
let content = backup.await?;
|
||||||
|
|
||||||
|
cx.update(|_window, _cx| {
|
||||||
|
state::web_file::download(
|
||||||
|
"encryption.txt",
|
||||||
|
"text/plain",
|
||||||
|
content.as_bytes(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))??;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
Command::ImportEncryption => {
|
Command::ImportEncryption => {
|
||||||
self.import_encryption(window, cx);
|
self.import_encryption(window, cx);
|
||||||
}
|
}
|
||||||
Command::Update => {
|
Command::Update =>
|
||||||
|
{
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||||
auto_updater.update(cx, |this, cx| this.check(cx));
|
auto_updater.update(cx, |this, cx| this.check(cx));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use gpui::{
|
|||||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
|
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
|
||||||
Styled, Window, div,
|
Styled, Window, div,
|
||||||
};
|
};
|
||||||
use gpui_kit::component::{ActiveTheme, Selectable, StyledExt as _, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Selectable, StyledExt as _, h_flex};
|
||||||
|
|
||||||
/// A single navigation row, such as an entry in a sidebar list.
|
/// A single navigation row, such as an entry in a sidebar list.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
@@ -19,6 +19,7 @@ pub struct Nav {
|
|||||||
suffix: Option<AnyElement>,
|
suffix: Option<AnyElement>,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
clickable: bool,
|
clickable: bool,
|
||||||
|
center_prefix: bool,
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,7 @@ impl Nav {
|
|||||||
suffix: None,
|
suffix: None,
|
||||||
selected: false,
|
selected: false,
|
||||||
clickable: false,
|
clickable: false,
|
||||||
|
center_prefix: false,
|
||||||
on_click: None,
|
on_click: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +50,12 @@ impl Nav {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether to center the prefix in the avatar-width column.
|
||||||
|
pub fn center_prefix(mut self, center: bool) -> Self {
|
||||||
|
self.center_prefix = center;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets the element shown before the label, such as an avatar or icon.
|
/// Sets the element shown before the label, such as an avatar or icon.
|
||||||
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||||
self.prefix = Some(prefix.into_any_element());
|
self.prefix = Some(prefix.into_any_element());
|
||||||
@@ -101,41 +109,34 @@ impl Styled for Nav {
|
|||||||
impl RenderOnce for Nav {
|
impl RenderOnce for Nav {
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let clickable = self.on_click.is_some() || self.clickable;
|
let clickable = self.on_click.is_some() || self.clickable;
|
||||||
let text_color = if self.selected {
|
|
||||||
cx.theme().foreground
|
|
||||||
} else {
|
|
||||||
cx.theme().muted_foreground
|
|
||||||
};
|
|
||||||
|
|
||||||
v_flex().h_10().w_full().justify_center().child(
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.id(self.id)
|
.id(self.id)
|
||||||
.h_9()
|
.h_8()
|
||||||
.w_full()
|
.w_full()
|
||||||
.px_1p5()
|
.px_2()
|
||||||
.gap_1p5()
|
.gap_2()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.text_color(text_color)
|
.when_some(self.prefix, |this, prefix| {
|
||||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
this.child(
|
||||||
.child(
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.flex_shrink_0()
|
||||||
.flex_1()
|
.when(self.center_prefix, |this| this.w_6().justify_center())
|
||||||
.min_w_0()
|
.child(prefix),
|
||||||
|
)
|
||||||
|
})
|
||||||
.child(div().flex_1().min_w_0().truncate().child(self.label))
|
.child(div().flex_1().min_w_0().truncate().child(self.label))
|
||||||
.when_some(self.suffix, |this, suffix| {
|
.when_some(self.suffix, |this, suffix| {
|
||||||
this.child(div().flex_shrink_0().child(suffix))
|
this.child(div().flex_shrink_0().child(suffix))
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
.when(clickable, |this| {
|
.when(clickable, |this| {
|
||||||
this.cursor_pointer()
|
this.cursor_pointer()
|
||||||
.hover(|this| this.bg(cx.theme().accent))
|
.hover(|this| this.bg(cx.theme().list_hover))
|
||||||
.when(self.selected, |this| this.bg(cx.theme().list_active))
|
.when(self.selected, |this| this.bg(cx.theme().list_active))
|
||||||
})
|
})
|
||||||
.when_some(self.on_click, |this, handler| {
|
.when_some(self.on_click, |this, handler| {
|
||||||
this.on_click(move |event, window, cx| handler(event, window, cx))
|
this.on_click(move |event, window, cx| handler(event, window, cx))
|
||||||
})
|
})
|
||||||
.refine_style(&self.style),
|
.refine_style(&self.style)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||||
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
|
ParentElement, Render, SharedString, Styled, Task, Window, div,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::clipboard::Clipboard;
|
||||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||||
use gpui_kit::component::input::{Input, InputState};
|
use gpui_kit::component::input::{Input, InputState};
|
||||||
use gpui_kit::component::separator::Separator;
|
use gpui_kit::component::separator::Separator;
|
||||||
use gpui_kit::component::{ActiveTheme, Sizable, v_flex};
|
use gpui_kit::component::{ActiveTheme, v_flex};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::USER_KEYRING;
|
use state::USER_KEYRING;
|
||||||
|
|
||||||
@@ -31,9 +30,6 @@ pub struct BackupPanel {
|
|||||||
/// Secret key input
|
/// Secret key input
|
||||||
nsec_input: Entity<InputState>,
|
nsec_input: Entity<InputState>,
|
||||||
|
|
||||||
/// Copied status
|
|
||||||
copied: bool,
|
|
||||||
|
|
||||||
/// Background tasks
|
/// Background tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
@@ -53,7 +49,6 @@ impl BackupPanel {
|
|||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
npub_input,
|
npub_input,
|
||||||
nsec_input,
|
nsec_input,
|
||||||
copied: false,
|
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,31 +75,6 @@ impl BackupPanel {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn copy_secret(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let value = self.nsec_input.read(cx).value();
|
|
||||||
let item = ClipboardItem::new_string(value.to_string());
|
|
||||||
cx.write_to_clipboard(item);
|
|
||||||
|
|
||||||
// Set the copied status to true
|
|
||||||
self.set_copied(true, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_copied(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.copied = status;
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
|
||||||
|
|
||||||
// Clear the error message after a delay
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_copied(false, cx);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl gpui_kit::component::dock::BasePanel for BackupPanel {
|
impl gpui_kit::component::dock::BasePanel for BackupPanel {
|
||||||
@@ -153,11 +123,11 @@ impl Render for BackupPanel {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child(SharedString::from("Public Key:")),
|
.child(SharedString::from("Public Key:")),
|
||||||
)
|
)
|
||||||
.child(Input::new(&self.npub_input).small().disabled(true)),
|
.child(Input::new(&self.npub_input).disabled(true)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -166,28 +136,19 @@ impl Render for BackupPanel {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child(SharedString::from("Secret Key:")),
|
.child(SharedString::from("Secret Key:")),
|
||||||
)
|
)
|
||||||
.child(Input::new(&self.nsec_input).small().disabled(true)),
|
.child(Input::new(&self.nsec_input).disabled(true)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("copy")
|
Clipboard::new("copy-secret")
|
||||||
.icon(IconName::Copy)
|
.accessibility_label("Copy secret key")
|
||||||
.label({
|
.value_fn({
|
||||||
if self.copied {
|
let nsec_input = self.nsec_input.clone();
|
||||||
"Copied"
|
move |_, cx| nsec_input.read(cx).value()
|
||||||
} else {
|
}),
|
||||||
"Copy secret key"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.primary()
|
|
||||||
.small()
|
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
|
||||||
this.copy_secret(cx);
|
|
||||||
})),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use gpui_base::{VirtualListScrollHandle, v_virtual_list};
|
use gpui_base::{VirtualListScrollHandle, v_virtual_list};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||||
@@ -269,7 +270,7 @@ impl BrowsePanel {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.line_height(relative(1.2))
|
.line_height(relative(1.2))
|
||||||
.child("Public Groups"),
|
.child("Public Groups"),
|
||||||
)
|
)
|
||||||
@@ -296,7 +297,7 @@ impl BrowsePanel {
|
|||||||
.px_1()
|
.px_1()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.bg(cx.theme().sidebar)
|
.bg(cx.theme().sidebar)
|
||||||
.child(Input::new(&self.input).appearance(false).small().flex_1())
|
.child(Input::new(&self.input).appearance(false).flex_1())
|
||||||
.child(
|
.child(
|
||||||
Button::new("relay-browse")
|
Button::new("relay-browse")
|
||||||
.icon(IconName::ArrowRight)
|
.icon(IconName::ArrowRight)
|
||||||
@@ -402,7 +403,7 @@ impl BrowsePanel {
|
|||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.truncate()
|
.truncate()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.line_height(relative(1.2))
|
.line_height(relative(1.2))
|
||||||
.child(name),
|
.child(name),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use gpui::{
|
|||||||
Window, div, rems, retain_all,
|
Window, div, rems, retain_all,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||||
@@ -325,7 +326,7 @@ impl Render for ContactListPanel {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child("New contact:"),
|
.child("New contact:"),
|
||||||
)
|
)
|
||||||
@@ -336,7 +337,7 @@ impl Render for ContactListPanel {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(Input::new(&self.input).small().cleanable(true))
|
.child(Input::new(&self.input).cleanable(true))
|
||||||
.child(
|
.child(
|
||||||
Button::new("add")
|
Button::new("add")
|
||||||
.icon(IconName::Plus)
|
.icon(IconName::Plus)
|
||||||
@@ -376,7 +377,6 @@ impl Render for ContactListPanel {
|
|||||||
.icon(IconName::CircleCheck)
|
.icon(IconName::CircleCheck)
|
||||||
.label("Update")
|
.label("Update")
|
||||||
.primary()
|
.primary()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.loading(self.updating)
|
.loading(self.updating)
|
||||||
.disabled(self.updating)
|
.disabled(self.updating)
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ use gpui::{
|
|||||||
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, svg,
|
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, svg,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||||
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants};
|
|
||||||
use gpui_kit::component::scroll::ScrollableElement;
|
use gpui_kit::component::scroll::ScrollableElement;
|
||||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -231,7 +231,7 @@ impl GreeterPanel {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.cursor_pointer()
|
.cursor_pointer()
|
||||||
.hover(|this| this.bg(cx.theme().primary))
|
.hover(|this| this.bg(cx.theme().list_hover))
|
||||||
.on_click({
|
.on_click({
|
||||||
let room = room.clone();
|
let room = room.clone();
|
||||||
let dock = dock.clone();
|
let dock = dock.clone();
|
||||||
@@ -244,13 +244,13 @@ impl GreeterPanel {
|
|||||||
.seed(seed)
|
.seed(seed)
|
||||||
.small(),
|
.small(),
|
||||||
)
|
)
|
||||||
.child(div().min_w_0().truncate().text_xs().child(name))
|
.child(div().min_w_0().truncate().text_sm().child(name))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.truncate()
|
.truncate()
|
||||||
.text_xs()
|
.text_sm()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.italic()
|
.italic()
|
||||||
.child(preview),
|
.child(preview),
|
||||||
@@ -301,11 +301,24 @@ impl GreeterPanel {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
GroupBox::new()
|
v_flex()
|
||||||
.id("requests")
|
.gap_2()
|
||||||
.title("Requests")
|
.child(
|
||||||
.fill()
|
div()
|
||||||
.child(v_flex().gap_0().w_full().children(rows))
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.child("Requests"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.px_2()
|
||||||
|
.py_1()
|
||||||
|
.rounded(cx.theme().radius_lg)
|
||||||
|
.bg(cx.theme().group_box)
|
||||||
|
.text_color(cx.theme().group_box_foreground)
|
||||||
|
.children(rows),
|
||||||
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +350,7 @@ impl GreeterPanel {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.cursor_pointer()
|
.cursor_pointer()
|
||||||
.hover(|this| this.bg(cx.theme().primary))
|
.hover(|this| this.bg(cx.theme().list_hover))
|
||||||
.on_click({
|
.on_click({
|
||||||
let room = room.clone();
|
let room = room.clone();
|
||||||
let dock = dock.clone();
|
let dock = dock.clone();
|
||||||
@@ -350,13 +363,13 @@ impl GreeterPanel {
|
|||||||
.seed(seed)
|
.seed(seed)
|
||||||
.small(),
|
.small(),
|
||||||
)
|
)
|
||||||
.child(div().min_w_0().truncate().text_xs().child(name))
|
.child(div().min_w_0().truncate().text_sm().child(name))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.truncate()
|
.truncate()
|
||||||
.text_xs()
|
.text_sm()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.italic()
|
.italic()
|
||||||
.child(preview),
|
.child(preview),
|
||||||
@@ -407,11 +420,24 @@ impl GreeterPanel {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Some(
|
Some(
|
||||||
GroupBox::new()
|
v_flex()
|
||||||
.id("follow-ups")
|
.gap_2()
|
||||||
.title("Follow ups")
|
.child(
|
||||||
.fill()
|
div()
|
||||||
.child(v_flex().gap_0().w_full().children(rows))
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.child("Follow ups"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.px_2()
|
||||||
|
.py_1()
|
||||||
|
.rounded(cx.theme().radius_lg)
|
||||||
|
.bg(cx.theme().group_box)
|
||||||
|
.text_color(cx.theme().group_box_foreground)
|
||||||
|
.children(rows),
|
||||||
|
)
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -424,7 +450,7 @@ impl GreeterPanel {
|
|||||||
div()
|
div()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.child("Find new friends"),
|
.child("Find new friends"),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -432,13 +458,9 @@ impl GreeterPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.child(
|
.child(
|
||||||
Nav::new("browse-group")
|
Nav::new("browse-group")
|
||||||
.prefix(
|
.prefix(Icon::new(IconName::Compass))
|
||||||
Icon::new(IconName::Compass)
|
|
||||||
.small()
|
|
||||||
.text_color(cx.theme().muted_foreground),
|
|
||||||
)
|
|
||||||
.label("Browse Public Groups")
|
.label("Browse Public Groups")
|
||||||
.text_xs()
|
.text_sm()
|
||||||
.on_click(cx.listener(|this, _, window, cx| {
|
.on_click(cx.listener(|this, _, window, cx| {
|
||||||
let panel = browse::init(this.dock.clone(), window, cx);
|
let panel = browse::init(this.dock.clone(), window, cx);
|
||||||
add_panel_to(
|
add_panel_to(
|
||||||
@@ -452,9 +474,9 @@ impl GreeterPanel {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Nav::new("browse-posts")
|
Nav::new("browse-posts")
|
||||||
.prefix(Icon::new(IconName::Link).small())
|
.prefix(Icon::new(IconName::Link))
|
||||||
.label("Browse Nostr via Jumble")
|
.label("Browse Nostr via Jumble")
|
||||||
.text_xs()
|
.text_sm()
|
||||||
.on_click(|_, _, cx| {
|
.on_click(|_, _, cx| {
|
||||||
cx.open_url(JUMBLE_URL);
|
cx.open_url(JUMBLE_URL);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,18 +4,16 @@ use std::rc::Rc;
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, ClipboardItem, Context, Entity, InteractiveElement as _, IntoElement,
|
AnyElement, ClipboardItem, Context, Entity, InteractiveElement as _, IntoElement,
|
||||||
ParentElement, SharedString, Styled, WeakEntity, div, relative, uniform_list,
|
ParentElement, SharedString, Styled, WeakEntity, div, px, relative, uniform_list,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||||
use gpui_kit::component::scroll::Scrollbar;
|
|
||||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||||
use nip29::Group;
|
use nip29::Group;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
use ui::Avatar;
|
use ui::{Avatar, IconName};
|
||||||
|
|
||||||
use super::Sidebar;
|
use super::Sidebar;
|
||||||
use crate::dialogs::profile;
|
use crate::dialogs::profile;
|
||||||
@@ -147,12 +145,11 @@ impl Sidebar {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.px_2()
|
.px_4()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.pl_0p5()
|
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.flex_shrink_1()
|
.flex_shrink_1()
|
||||||
.child(Avatar::new(picture).seed(seed.clone()).small())
|
.child(Avatar::new(picture).seed(seed.clone()).small())
|
||||||
@@ -164,13 +161,13 @@ impl Sidebar {
|
|||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.truncate()
|
.truncate()
|
||||||
.line_height(relative(1.2))
|
.line_height(relative(1.125))
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.child(name),
|
.child(name),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_size(px(8.0))
|
||||||
.font_medium()
|
.font_medium()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child(total_members),
|
.child(total_members),
|
||||||
@@ -184,28 +181,17 @@ impl Sidebar {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div().min_h_0().flex_1().child(
|
||||||
.min_h_0()
|
|
||||||
.flex_1()
|
|
||||||
.child(
|
|
||||||
uniform_list(
|
uniform_list(
|
||||||
"group-rows",
|
"group-rows",
|
||||||
rows.len(),
|
rows.len(),
|
||||||
cx.processor(move |this, range, _window, cx| {
|
cx.processor(move |this, range, _window, cx| {
|
||||||
this.render_group_rows(
|
this.render_group_rows(range, rows.as_slice(), &member_group, admin, cx)
|
||||||
range,
|
|
||||||
rows.as_slice(),
|
|
||||||
&member_group,
|
|
||||||
admin,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.track_scroll(&self.group_scroll)
|
|
||||||
.h_full()
|
.h_full()
|
||||||
.px_2(),
|
.px_2(),
|
||||||
)
|
),
|
||||||
.child(Scrollbar::vertical(&self.group_scroll)),
|
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -236,9 +222,9 @@ impl Sidebar {
|
|||||||
GroupSection::Members => self.members_open,
|
GroupSection::Members => self.members_open,
|
||||||
};
|
};
|
||||||
let icon = if open {
|
let icon = if open {
|
||||||
IconName::ChevronDown
|
IconName::CaretDown
|
||||||
} else {
|
} else {
|
||||||
IconName::ChevronRight
|
IconName::CaretRight
|
||||||
};
|
};
|
||||||
let label = section.label();
|
let label = section.label();
|
||||||
|
|
||||||
@@ -250,7 +236,7 @@ impl Sidebar {
|
|||||||
.text_color(cx.theme().muted_foreground),
|
.text_color(cx.theme().muted_foreground),
|
||||||
)
|
)
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||||
match section {
|
match section {
|
||||||
@@ -285,8 +271,7 @@ impl Sidebar {
|
|||||||
|
|
||||||
let nav = Nav::new(public_key.to_hex())
|
let nav = Nav::new(public_key.to_hex())
|
||||||
.label(person.name())
|
.label(person.name())
|
||||||
.text_xs()
|
.text_sm()
|
||||||
.font_weight(gpui::FontWeight::MEDIUM)
|
|
||||||
.clickable(true)
|
.clickable(true)
|
||||||
.when(!hide_avatar, |this| {
|
.when(!hide_avatar, |this| {
|
||||||
this.prefix(
|
this.prefix(
|
||||||
@@ -298,6 +283,7 @@ impl Sidebar {
|
|||||||
.when(!roles.is_empty(), |this| {
|
.when(!roles.is_empty(), |this| {
|
||||||
this.suffix(
|
this.suffix(
|
||||||
div()
|
div()
|
||||||
|
.font_normal()
|
||||||
.truncate()
|
.truncate()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui::{AnyElement, Entity, IntoElement};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement,
|
App, Context, Div, InteractiveElement, ParentElement, Stateful, Styled, Window, div, px,
|
||||||
Stateful, Styled, div, px,
|
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::Disableable;
|
||||||
use gpui_kit::component::menu::DropdownMenu;
|
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
||||||
use gpui_kit::component::{Disableable, Selectable, Sizable, h_flex};
|
use gpui_kit::component::dialog::DialogButtonProps;
|
||||||
|
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
||||||
|
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use ui::Avatar;
|
use state::{CLIENT_NAME, NostrRegistry};
|
||||||
|
use ui::{Avatar, IconName};
|
||||||
|
|
||||||
use super::Sidebar;
|
use super::Sidebar;
|
||||||
use crate::Command;
|
use crate::Command;
|
||||||
use crate::dock::TAB_BAR_HEIGHT;
|
use crate::dock::TAB_BAR_HEIGHT;
|
||||||
|
use crate::nav::Nav;
|
||||||
|
|
||||||
impl Sidebar {
|
impl Sidebar {
|
||||||
pub(super) fn render_user(
|
pub(super) fn render_user(
|
||||||
@@ -21,6 +27,7 @@ impl Sidebar {
|
|||||||
current_user: &PublicKey,
|
current_user: &PublicKey,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Stateful<Div> {
|
) -> Stateful<Div> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let updater = AutoUpdater::try_global(cx);
|
let updater = AutoUpdater::try_global(cx);
|
||||||
let profile = self.directory.read(cx).person(current_user, cx);
|
let profile = self.directory.read(cx).person(current_user, cx);
|
||||||
let avatar = profile.avatar();
|
let avatar = profile.avatar();
|
||||||
@@ -30,44 +37,40 @@ impl Sidebar {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.h(TAB_BAR_HEIGHT)
|
.h(TAB_BAR_HEIGHT)
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.items_center()
|
.justify_between()
|
||||||
.gap_2()
|
.px_2p5()
|
||||||
.px_2()
|
|
||||||
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
|
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
|
||||||
.child(
|
.child(
|
||||||
Button::new("current-user")
|
Button::new("user")
|
||||||
.child(
|
|
||||||
Avatar::new(avatar.clone())
|
|
||||||
.seed(profile.avatar_seed())
|
|
||||||
.small(),
|
|
||||||
)
|
|
||||||
.small()
|
|
||||||
.dropdown_caret(true)
|
|
||||||
.compact()
|
|
||||||
.text()
|
.text()
|
||||||
|
.dropdown_caret(true)
|
||||||
|
.min_w_0()
|
||||||
|
.flex_shrink_1()
|
||||||
|
.child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
|
||||||
.dropdown_menu(move |this, _window, _cx| {
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
this.menu_with_icon(
|
this.menu("Profile", Box::new(Command::ShowProfile))
|
||||||
"Profile",
|
.menu("Relays", Box::new(Command::ShowRelayList))
|
||||||
IconName::UserRound,
|
|
||||||
Box::new(Command::ShowProfile),
|
|
||||||
)
|
|
||||||
.menu_with_icon(
|
|
||||||
"Relays",
|
|
||||||
IconName::RadioTower,
|
|
||||||
Box::new(Command::ShowRelayList),
|
|
||||||
)
|
|
||||||
.separator()
|
.separator()
|
||||||
.menu_with_icon(
|
.label(format!("{CLIENT_NAME} {}", env!("CARGO_PKG_VERSION")))
|
||||||
"Settings",
|
.item(
|
||||||
IconName::Settings,
|
PopupMenuItem::element(|_, cx| {
|
||||||
Box::new(Command::ShowSettings),
|
div().text_color(cx.theme().danger).child("Log out")
|
||||||
|
})
|
||||||
|
.on_click(|_, window, cx| {
|
||||||
|
open_logout_dialog(window, cx);
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(div().flex_1())
|
.child({
|
||||||
.when_some(updater, |this, updater| {
|
let actions = h_flex().gap_1();
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let actions = actions.when_some(updater, |this, updater| {
|
||||||
this.child(self.render_updater(updater, cx))
|
this.child(self.render_updater(updater, cx))
|
||||||
})
|
});
|
||||||
|
|
||||||
|
actions
|
||||||
.child(
|
.child(
|
||||||
Button::new("sidebar-search")
|
Button::new("sidebar-search")
|
||||||
.icon(IconName::Search)
|
.icon(IconName::Search)
|
||||||
@@ -81,28 +84,16 @@ impl Sidebar {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("sidebar-new")
|
Button::new("sidebar-new")
|
||||||
.icon(IconName::MessageSquarePlus)
|
.icon(IconName::PlusChat)
|
||||||
.tooltip("New")
|
.tooltip("New")
|
||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.dropdown_menu(|menu, _window, _cx| {
|
.dropdown_menu(|menu, _window, _cx| {
|
||||||
menu.label("Public Group")
|
menu.label("Public Group")
|
||||||
.menu_with_icon(
|
.menu("New Group", Box::new(Command::NewGroup))
|
||||||
"New Group",
|
.menu("Join Group", Box::new(Command::JoinGroup))
|
||||||
IconName::Users,
|
|
||||||
Box::new(Command::NewGroup),
|
|
||||||
)
|
|
||||||
.menu_with_icon(
|
|
||||||
"Join Group",
|
|
||||||
IconName::DoorOpen,
|
|
||||||
Box::new(Command::JoinGroup),
|
|
||||||
)
|
|
||||||
.separator()
|
.separator()
|
||||||
.menu_with_icon(
|
.menu("New Chat", Box::new(Command::NewChat))
|
||||||
"New Chat",
|
|
||||||
IconName::MessageSquare,
|
|
||||||
Box::new(Command::NewChat),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.when(self.group_open, |this| {
|
.when(self.group_open, |this| {
|
||||||
@@ -117,8 +108,31 @@ impl Sidebar {
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context<Self>) -> Div {
|
||||||
|
v_flex()
|
||||||
|
.p_2()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.gap_1()
|
||||||
|
.items_start()
|
||||||
|
.justify_start()
|
||||||
|
.when(!loading, |this| {
|
||||||
|
this.child(
|
||||||
|
Nav::new("settings")
|
||||||
|
.prefix(Icon::new(IconName::Settings).small())
|
||||||
|
.center_prefix(true)
|
||||||
|
.label("Settings")
|
||||||
|
.text_sm()
|
||||||
|
.on_click(|_, window, cx| {
|
||||||
|
window.dispatch_action(Box::new(Command::ShowSettings), cx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
|
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
|
||||||
let status = updater.read(cx).status();
|
let status = updater.read(cx).status();
|
||||||
let up_to_date = updater.read(cx).up_to_date();
|
let up_to_date = updater.read(cx).up_to_date();
|
||||||
@@ -129,7 +143,7 @@ impl Sidebar {
|
|||||||
.when(!up_to_date, |this| {
|
.when(!up_to_date, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("update")
|
Button::new("update")
|
||||||
.icon(IconName::CircleArrowDown)
|
.icon(IconName::ArrowDownCircle)
|
||||||
.tooltip(status)
|
.tooltip(status)
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
@@ -139,7 +153,7 @@ impl Sidebar {
|
|||||||
.when(staged, |this| {
|
.when(staged, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("restart")
|
Button::new("restart")
|
||||||
.icon(IconName::CircleArrowDown)
|
.icon(IconName::ArrowDownCircle)
|
||||||
.tooltip("Quit and relaunch into the installed update")
|
.tooltip("Quit and relaunch into the installed update")
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
@@ -153,3 +167,25 @@ impl Sidebar {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Confirm signing out, then drop the stored credential like signed's exit dialog.
|
||||||
|
fn open_logout_dialog(window: &mut Window, cx: &mut App) {
|
||||||
|
const DESCRIPTION: &str = "Are you sure you want to log out? You will need to re-enter your credentials to sign back in.";
|
||||||
|
|
||||||
|
window.open_alert_dialog(cx, |alert, _, _| {
|
||||||
|
alert
|
||||||
|
.title("Log Out?")
|
||||||
|
.description(DESCRIPTION)
|
||||||
|
.button_props(
|
||||||
|
DialogButtonProps::default()
|
||||||
|
.ok_text("Log out")
|
||||||
|
.ok_variant(ButtonVariant::Danger)
|
||||||
|
.show_cancel(true),
|
||||||
|
)
|
||||||
|
.on_ok(|_, _, cx| {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
nostr.update(cx, |nostr, cx| nostr.sign_out(cx));
|
||||||
|
true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use gpui::{
|
|||||||
AnyElement, App, AppContext, Context, IntoElement, ParentElement, SharedString, Styled, Task,
|
AnyElement, App, AppContext, Context, IntoElement, ParentElement, SharedString, Styled, Task,
|
||||||
Window, div,
|
Window, div,
|
||||||
};
|
};
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::dock::{DockPlacement, PanelHandle};
|
use gpui_kit::component::dock::{DockPlacement, PanelHandle};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
@@ -192,9 +193,8 @@ impl Sidebar {
|
|||||||
.h_20()
|
.h_20()
|
||||||
.w_full()
|
.w_full()
|
||||||
.px_4()
|
.px_4()
|
||||||
.border_t_1()
|
.bg(cx.theme().sidebar)
|
||||||
.border_color(cx.theme().border)
|
.text_color(cx.theme().sidebar_foreground)
|
||||||
.bg(cx.theme().background)
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("create-chat")
|
Button::new("create-chat")
|
||||||
.label(if self.selected_contacts.len() > 1 {
|
.label(if self.selected_contacts.len() > 1 {
|
||||||
@@ -212,10 +212,12 @@ impl Sidebar {
|
|||||||
.gap_1()
|
.gap_1()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child("Selected")
|
.text_center()
|
||||||
|
.justify_center()
|
||||||
|
.child("Selected:")
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.font_semibold()
|
||||||
.child(SharedString::from(self.selected_contacts.len().to_string())),
|
.child(SharedString::from(self.selected_contacts.len().to_string())),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ use gpui::prelude::FluentBuilder;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled, div, uniform_list,
|
AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled, div, uniform_list,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::scroll::Scrollbar;
|
|
||||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||||
use nip29::GroupsRegistry;
|
use nip29::GroupsRegistry;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
|
use ui::IconName;
|
||||||
|
|
||||||
use super::Sidebar;
|
use super::Sidebar;
|
||||||
use super::rows::SidebarRow;
|
use super::rows::SidebarRow;
|
||||||
@@ -44,12 +43,12 @@ impl Sidebar {
|
|||||||
// Whether to show the create button for the contact picker
|
// Whether to show the create button for the contact picker
|
||||||
let create_button = contacts_open && !self.selected_contacts.is_empty();
|
let create_button = contacts_open && !self.selected_contacts.is_empty();
|
||||||
|
|
||||||
let (list_id, scroll_handle, list_title) = if contacts_open {
|
let (list_id, title) = if contacts_open {
|
||||||
("sidebar-contacts", &self.contacts_scroll, Some("Contacts"))
|
("sidebar-contacts", Some("Contacts"))
|
||||||
} else if requests_open {
|
} else if requests_open {
|
||||||
("sidebar-requests", &self.requests_scroll, Some("Requests"))
|
("sidebar-requests", Some("Requests"))
|
||||||
} else {
|
} else {
|
||||||
("sidebar-list", &self.list_scroll, None)
|
("sidebar-list", None)
|
||||||
};
|
};
|
||||||
|
|
||||||
let request_count = ChatRegistry::global(cx)
|
let request_count = ChatRegistry::global(cx)
|
||||||
@@ -61,28 +60,30 @@ impl Sidebar {
|
|||||||
.flex_1()
|
.flex_1()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
.relative()
|
.relative()
|
||||||
.gap_2()
|
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.px_2()
|
.px_2()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
|
.items_start()
|
||||||
|
.justify_start()
|
||||||
.child(
|
.child(
|
||||||
Nav::new("browse")
|
Nav::new("browse")
|
||||||
.prefix(Icon::new(IconName::Compass).small())
|
.prefix(Icon::new(IconName::Compass).small())
|
||||||
.label("Browse")
|
.label("Browse")
|
||||||
|
.text_sm()
|
||||||
.on_click(|_, window, cx| {
|
.on_click(|_, window, cx| {
|
||||||
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
|
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Nav::new("reqs")
|
Nav::new("reqs")
|
||||||
.prefix(Icon::new(IconName::UserRoundPlus).small())
|
.prefix(Icon::new(IconName::Invite).small())
|
||||||
.label("Requests")
|
.label("Requests")
|
||||||
|
.text_sm()
|
||||||
.when(request_count > 0, |this| {
|
.when(request_count > 0, |this| {
|
||||||
this.suffix(
|
this.suffix(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.items_center()
|
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.py_0p5()
|
.py_0p5()
|
||||||
@@ -118,26 +119,26 @@ impl Sidebar {
|
|||||||
Nav::new("contacts")
|
Nav::new("contacts")
|
||||||
.prefix(Icon::new(IconName::Book).small())
|
.prefix(Icon::new(IconName::Book).small())
|
||||||
.label("Contacts")
|
.label("Contacts")
|
||||||
|
.text_sm()
|
||||||
.when(contacts_open, |this| this.bg(cx.theme().list_active))
|
.when(contacts_open, |this| this.bg(cx.theme().list_active))
|
||||||
.on_click(cx.listener(|this, _event, window, cx| {
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
this.toggle_contacts(window, cx);
|
this.toggle_contacts(window, cx);
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.when_some(list_title, |this, list_title| {
|
.when_some(title, |this, title| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
h_flex()
|
||||||
.px_4()
|
.px_4()
|
||||||
.text_xs()
|
.h_10()
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.w_full()
|
||||||
|
.flex_shrink_0()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child(list_title),
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.child(title),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.min_h_0()
|
|
||||||
.flex_1()
|
|
||||||
.child(
|
.child(
|
||||||
uniform_list(
|
uniform_list(
|
||||||
list_id,
|
list_id,
|
||||||
@@ -146,12 +147,10 @@ impl Sidebar {
|
|||||||
this.render_rows(range, rows.as_slice(), cx)
|
this.render_rows(range, rows.as_slice(), cx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.track_scroll(scroll_handle)
|
.min_h_0()
|
||||||
.h_full()
|
.flex_1()
|
||||||
.px_2(),
|
.px_2(),
|
||||||
)
|
)
|
||||||
.child(Scrollbar::vertical(scroll_handle)),
|
|
||||||
)
|
|
||||||
.when(create_button, |this| {
|
.when(create_button, |this| {
|
||||||
this.child(self.render_create_overlay(cx))
|
this.child(self.render_create_overlay(cx))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,19 +5,22 @@ use chat::{ChatEvent, ChatRegistry};
|
|||||||
use common::DebouncedDelay;
|
use common::DebouncedDelay;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled, StyledImage, Subscription,
|
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled,
|
||||||
Task, UniformListScrollHandle, WeakEntity, Window, div, img, retain_all,
|
StyledImage, Subscription, Task, WeakEntity, Window, div, img, retain_all,
|
||||||
};
|
};
|
||||||
|
use gpui_kit::base::{Presence, Transition};
|
||||||
use gpui_kit::component::dock::{DockArea, Panel, PanelEvent};
|
use gpui_kit::component::dock::{DockArea, Panel, PanelEvent};
|
||||||
use gpui_kit::component::input::{InputEvent, InputState};
|
use gpui_kit::component::input::{InputEvent, InputState};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
use gpui_kit::component::spinner::Spinner;
|
use gpui_kit::component::progress::ProgressCircle;
|
||||||
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, WindowExt, h_flex, v_flex};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
|
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Directory, PersonRegistry};
|
use person::{Directory, PersonRegistry};
|
||||||
|
pub(crate) use rows::open_room;
|
||||||
|
use search::SearchResult;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
||||||
use ui::title_bar_drag_handlers;
|
use ui::title_bar_drag_handlers;
|
||||||
@@ -32,12 +35,11 @@ mod onboarding;
|
|||||||
mod rows;
|
mod rows;
|
||||||
mod search;
|
mod search;
|
||||||
|
|
||||||
pub(crate) use rows::open_room;
|
const PROGRESS_TICK_MS: u64 = 500;
|
||||||
use search::SearchResult;
|
const PROGRESS_CAP: f32 = 95.0;
|
||||||
|
|
||||||
pub struct Sidebar {
|
pub struct Sidebar {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
list_scroll: UniformListScrollHandle,
|
|
||||||
dock: WeakEntity<DockArea>,
|
dock: WeakEntity<DockArea>,
|
||||||
/// Random background shown behind the signed-out screen.
|
/// Random background shown behind the signed-out screen.
|
||||||
banner: SharedString,
|
banner: SharedString,
|
||||||
@@ -46,8 +48,6 @@ pub struct Sidebar {
|
|||||||
group: Option<WeakEntity<Group>>,
|
group: Option<WeakEntity<Group>>,
|
||||||
/// Whether the sidebar is showing the group's information instead of its list.
|
/// Whether the sidebar is showing the group's information instead of its list.
|
||||||
group_open: bool,
|
group_open: bool,
|
||||||
/// Scroll state of the group's member list.
|
|
||||||
group_scroll: UniformListScrollHandle,
|
|
||||||
/// Whether the group view's admins section is expanded.
|
/// Whether the group view's admins section is expanded.
|
||||||
admins_open: bool,
|
admins_open: bool,
|
||||||
/// Whether the group view's members section is expanded.
|
/// Whether the group view's members section is expanded.
|
||||||
@@ -68,13 +68,10 @@ pub struct Sidebar {
|
|||||||
directory: Entity<Directory>,
|
directory: Entity<Directory>,
|
||||||
/// Contacts selected in the contact picker.
|
/// Contacts selected in the contact picker.
|
||||||
selected_contacts: HashSet<PublicKey>,
|
selected_contacts: HashSet<PublicKey>,
|
||||||
/// Scroll state of the contact picker's list.
|
|
||||||
contacts_scroll: UniformListScrollHandle,
|
|
||||||
|
|
||||||
/// Whether the main list is showing chat requests instead of chats and groups.
|
/// Whether the main list is showing chat requests instead of chats and groups.
|
||||||
requests_open: bool,
|
requests_open: bool,
|
||||||
new_requests: bool,
|
new_requests: bool,
|
||||||
requests_scroll: UniformListScrollHandle,
|
|
||||||
|
|
||||||
/// Whether the search bar is shown below the header.
|
/// Whether the search bar is shown below the header.
|
||||||
search_open: bool,
|
search_open: bool,
|
||||||
@@ -92,8 +89,11 @@ pub struct Sidebar {
|
|||||||
global_results: Option<Vec<PublicKey>>,
|
global_results: Option<Vec<PublicKey>>,
|
||||||
/// The async global profile search.
|
/// The async global profile search.
|
||||||
global_task: Option<Task<()>>,
|
global_task: Option<Task<()>>,
|
||||||
/// Scroll state of the search results list.
|
|
||||||
search_scroll: UniformListScrollHandle,
|
/// Simulated gift wrap processing progress, shown while the backlog loads
|
||||||
|
chat_progress: f32,
|
||||||
|
/// Simulated web of trust sync progress
|
||||||
|
sync_progress: f32,
|
||||||
|
|
||||||
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
||||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||||
@@ -104,7 +104,6 @@ impl Sidebar {
|
|||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let groups = GroupsRegistry::global(cx);
|
let groups = GroupsRegistry::global(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let wot = PersonRegistry::global(cx).read(cx).wot();
|
|
||||||
|
|
||||||
let find_input = cx.new(|cx| {
|
let find_input = cx.new(|cx| {
|
||||||
InputState::new(window, cx)
|
InputState::new(window, cx)
|
||||||
@@ -112,20 +111,22 @@ impl Sidebar {
|
|||||||
.clean_on_escape()
|
.clean_on_escape()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mut tasks = smallvec![];
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
|
cx.subscribe_in(&chat, window, move |this, _, event, _, cx| {
|
||||||
if event == &ChatEvent::Ping {
|
match event {
|
||||||
|
ChatEvent::Ping => {
|
||||||
this.new_requests = true;
|
this.new_requests = true;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
};
|
}
|
||||||
}),
|
ChatEvent::Loaded => {
|
||||||
);
|
this.chat_progress = 0.0;
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
cx.subscribe_in(&wot, window, |_this, _wot, _event, _window, cx| {
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -133,7 +134,7 @@ impl Sidebar {
|
|||||||
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
|
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
|
||||||
match event {
|
match event {
|
||||||
GroupsEvent::Updated => {
|
GroupsEvent::Updated => {
|
||||||
// TODO: find a better way?
|
// TODO: we must not trigger re-render directly like this
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
GroupsEvent::Error(error) => {
|
GroupsEvent::Error(error) => {
|
||||||
@@ -205,14 +206,22 @@ impl Sidebar {
|
|||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
|
tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let timer = Duration::from_millis(PROGRESS_TICK_MS);
|
||||||
|
loop {
|
||||||
|
cx.background_executor().timer(timer).await;
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.tick_progress(cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
list_scroll: UniformListScrollHandle::new(),
|
|
||||||
dock,
|
dock,
|
||||||
banner: onboarding::pick_banner(),
|
banner: onboarding::pick_banner(),
|
||||||
group: None,
|
group: None,
|
||||||
group_open: false,
|
group_open: false,
|
||||||
group_scroll: UniformListScrollHandle::new(),
|
|
||||||
admins_open: true,
|
admins_open: true,
|
||||||
members_open: true,
|
members_open: true,
|
||||||
pins_open: true,
|
pins_open: true,
|
||||||
@@ -222,9 +231,7 @@ impl Sidebar {
|
|||||||
contacts: None,
|
contacts: None,
|
||||||
directory: Directory::new(cx),
|
directory: Directory::new(cx),
|
||||||
selected_contacts: HashSet::new(),
|
selected_contacts: HashSet::new(),
|
||||||
contacts_scroll: UniformListScrollHandle::new(),
|
|
||||||
requests_open: false,
|
requests_open: false,
|
||||||
requests_scroll: UniformListScrollHandle::new(),
|
|
||||||
new_requests: false,
|
new_requests: false,
|
||||||
search_open: false,
|
search_open: false,
|
||||||
global_open: false,
|
global_open: false,
|
||||||
@@ -234,8 +241,9 @@ impl Sidebar {
|
|||||||
global_finding: false,
|
global_finding: false,
|
||||||
global_results: None,
|
global_results: None,
|
||||||
global_task: None,
|
global_task: None,
|
||||||
search_scroll: UniformListScrollHandle::new(),
|
chat_progress: 0.0,
|
||||||
tasks: smallvec![],
|
sync_progress: 0.0,
|
||||||
|
tasks,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,6 +271,80 @@ impl Sidebar {
|
|||||||
self.hide_group(cx);
|
self.hide_group(cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advance the simulated loading progress of any active background work.
|
||||||
|
fn tick_progress(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let logged_in = NostrRegistry::global(cx).read(cx).current_user().is_some();
|
||||||
|
let chat = ChatRegistry::global(cx);
|
||||||
|
let wot = PersonRegistry::global(cx).read(cx).wot();
|
||||||
|
|
||||||
|
let chat_loading = logged_in && chat.read(cx).loading();
|
||||||
|
let syncing = logged_in && wot.read(cx).is_syncing();
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
if chat_loading {
|
||||||
|
let next = self.advance_progress(self.chat_progress);
|
||||||
|
if next > self.chat_progress {
|
||||||
|
self.chat_progress = next;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if self.chat_progress != 0.0 {
|
||||||
|
self.chat_progress = 0.0;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if syncing {
|
||||||
|
let next = self.advance_progress(self.sync_progress);
|
||||||
|
if next > self.sync_progress {
|
||||||
|
self.sync_progress = next;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if self.sync_progress != 0.0 {
|
||||||
|
self.sync_progress = 0.0;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bump simulated progress by a random step
|
||||||
|
fn advance_progress(&self, progress: f32) -> f32 {
|
||||||
|
(progress + rand::random::<f32>() * 8.0 + 1.0).min(PROGRESS_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A floating pill showing simulated progress for a background operation.
|
||||||
|
fn render_loading_pill(
|
||||||
|
&self,
|
||||||
|
id: &'static str,
|
||||||
|
label: &'static str,
|
||||||
|
progress: f32,
|
||||||
|
opacity: f32,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> Div {
|
||||||
|
h_flex()
|
||||||
|
.opacity(opacity)
|
||||||
|
.h_9()
|
||||||
|
.w_full()
|
||||||
|
.px_5()
|
||||||
|
.gap_2p5()
|
||||||
|
.border_t_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.child(
|
||||||
|
ProgressCircle::new(id)
|
||||||
|
.value(progress)
|
||||||
|
.color(cx.theme().primary)
|
||||||
|
.accessibility_label(label),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().sidebar_foreground)
|
||||||
|
.child(label),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl gpui_kit::component::dock::BasePanel for Sidebar {
|
impl gpui_kit::component::dock::BasePanel for Sidebar {
|
||||||
@@ -302,15 +384,24 @@ impl Render for Sidebar {
|
|||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let loading = chat.read(cx).loading();
|
let loading = chat.read(cx).loading();
|
||||||
|
|
||||||
|
let wot = PersonRegistry::global(cx).read(cx).wot();
|
||||||
|
let syncing = wot.read(cx).is_syncing();
|
||||||
|
|
||||||
let group = self.group.clone().and_then(|group| group.upgrade());
|
let group = self.group.clone().and_then(|group| group.upgrade());
|
||||||
let searching = self.search_open && !self.find_input.read(cx).value().is_empty();
|
let searching = self.search_open && !self.find_input.read(cx).value().is_empty();
|
||||||
|
let picking_contacts = self.contacts_open && !self.requests_open;
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(retain_all("sidebar"))
|
.image_cache(retain_all("sidebar"))
|
||||||
.size_full()
|
.size_full()
|
||||||
.relative()
|
.relative()
|
||||||
.gap_2()
|
.justify_between()
|
||||||
.bg(cx.theme().sidebar)
|
.bg(cx.theme().sidebar)
|
||||||
|
.text_color(cx.theme().sidebar_foreground)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_h_0()
|
||||||
.when_some(current_user.as_ref(), |this, current_user| {
|
.when_some(current_user.as_ref(), |this, current_user| {
|
||||||
this.child(title_bar_drag_handlers(
|
this.child(title_bar_drag_handlers(
|
||||||
self.render_user(current_user, cx),
|
self.render_user(current_user, cx),
|
||||||
@@ -320,8 +411,7 @@ impl Render for Sidebar {
|
|||||||
})
|
})
|
||||||
.when(self.search_open, |this| this.child(self.render_find(cx)))
|
.when(self.search_open, |this| this.child(self.render_find(cx)))
|
||||||
.when(!logged_in, |this| {
|
.when(!logged_in, |this| {
|
||||||
this.relative()
|
this.child(title_bar_drag_handlers(
|
||||||
.child(title_bar_drag_handlers(
|
|
||||||
div()
|
div()
|
||||||
.id("onboarding-drag")
|
.id("onboarding-drag")
|
||||||
.absolute()
|
.absolute()
|
||||||
@@ -347,31 +437,49 @@ impl Render for Sidebar {
|
|||||||
}
|
}
|
||||||
_ if searching => this.child(self.render_results(cx)),
|
_ if searching => this.child(self.render_results(cx)),
|
||||||
_ => this.child(self.render_list(cx)),
|
_ => this.child(self.render_list(cx)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.when(logged_in && !picking_contacts, |this| {
|
||||||
|
this.child(self.render_footer(loading || syncing, cx))
|
||||||
})
|
})
|
||||||
.when(loading && logged_in, |this| {
|
.when(logged_in, |this| {
|
||||||
|
let pill_motion = Transition::new(cx.theme().motion_tokens().duration_normal)
|
||||||
|
.easing(cx.theme().motion_tokens().easing_move.clone());
|
||||||
|
|
||||||
|
let chat_presence = Presence::new("chat-presence", loading)
|
||||||
|
.transition(pill_motion.clone())
|
||||||
|
.sample(window, cx);
|
||||||
|
|
||||||
|
let wot_presence = Presence::new("wot-presence", syncing)
|
||||||
|
.transition(pill_motion)
|
||||||
|
.sample(window, cx);
|
||||||
|
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
v_flex()
|
||||||
.absolute()
|
.absolute()
|
||||||
.bottom_4()
|
.bottom_0()
|
||||||
.left_0()
|
.left_0()
|
||||||
.h_9()
|
|
||||||
.w_full()
|
.w_full()
|
||||||
.px_8()
|
.bg(cx.theme().sidebar)
|
||||||
.child(
|
.text_color(cx.theme().sidebar_foreground)
|
||||||
h_flex()
|
.children(chat_presence.should_render().then(|| {
|
||||||
.gap_2()
|
self.render_loading_pill(
|
||||||
.w_full()
|
"chat-sync",
|
||||||
.h_9()
|
"Syncing messages",
|
||||||
.justify_center()
|
self.chat_progress,
|
||||||
.bg(cx.theme().background.opacity(0.85))
|
chat_presence.progress,
|
||||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
cx,
|
||||||
.rounded_full()
|
)
|
||||||
.text_xs()
|
}))
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
.children(wot_presence.should_render().then(|| {
|
||||||
.text_color(cx.theme().muted_foreground)
|
self.render_loading_pill(
|
||||||
.child(Spinner::new().small().color(cx.theme().primary))
|
"wot-sync",
|
||||||
.child("Getting messages..."),
|
"Syncing contacts",
|
||||||
),
|
self.sync_progress,
|
||||||
|
wot_presence.progress,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
|
|||||||