Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a7afbe3e2 | ||
|
|
2f9559228f | ||
|
|
7e89f63a7c | ||
|
|
baea9cd9ff | ||
|
|
9ec8b128db | ||
|
|
4166b30319 | ||
|
|
6fc7732128 | ||
|
|
41b6c80057 | ||
|
|
98a12e438a | ||
|
|
c1757bbcae | ||
|
|
74d548e5c6 | ||
|
|
690dc37fc5 | ||
|
|
2f2661ecd2 | ||
|
|
d045a7ef3a | ||
|
|
23d7017f25 | ||
|
|
af2a276b1b |
@@ -39,8 +39,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust nightly
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: nightly-2026-10-31
|
||||
targets: wasm32-unknown-unknown
|
||||
components: llvm-tools-preview
|
||||
|
||||
@@ -51,4 +52,4 @@ jobs:
|
||||
env:
|
||||
CC_wasm32_unknown_unknown: clang
|
||||
AR_wasm32_unknown_unknown: llvm-ar
|
||||
run: cargo +nightly check -p coop_web --target wasm32-unknown-unknown
|
||||
run: cargo +nightly-2026-10-31 check -p coop_web --target wasm32-unknown-unknown
|
||||
|
||||
@@ -9,22 +9,18 @@ edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
gpui = { package = "gpui-pre", version = "0.3.8" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.8" }
|
||||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.8" }
|
||||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.8" }
|
||||
gpui_web = { package = "gpui-pre-web", version = "0.3.8" }
|
||||
gpui_util = { package = "gpui-pre-util", version = "0.3.8" }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
|
||||
gpui = { package = "gpui-pre", version = "0.3.10" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.10", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.10" }
|
||||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.10" }
|
||||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.10" }
|
||||
gpui_web = { package = "gpui-pre-web", version = "0.3.10" }
|
||||
gpui_util = { package = "gpui-pre-util", version = "0.3.10" }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.10" }
|
||||
gpui_tokio = { path = "crates/gpui_tokio" }
|
||||
gpui-base = "0.7.1"
|
||||
gpui-kit = "0.7.3"
|
||||
|
||||
# Native OS theme reading; the connector is written against gpui-component 0.7.x
|
||||
# and gpui-pre 0.3.8, both satisfied by the versions above (0.7.3 / 0.3.9).
|
||||
native-theme = { version = "0.6.1", features = ["watch"] }
|
||||
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
|
||||
gpui-base = "0.8.1"
|
||||
gpui-kit = "0.8.1"
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
@@ -35,7 +31,7 @@ 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-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", "nip60", "nip98" ] }
|
||||
|
||||
# Crypto (NIP-17 encrypted file messages)
|
||||
aes-gcm = "0.10"
|
||||
@@ -48,9 +44,13 @@ chacha20 = "0.9"
|
||||
hmac = "0.12"
|
||||
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
|
||||
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng", "thread_rng" ] }
|
||||
# Cashu (NIP-60).
|
||||
cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
|
||||
cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
tts = { git = "https://github.com/ndarilek/tts-rs" }
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
@@ -66,6 +66,12 @@ webbrowser = "1.0.4"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
instant = "0.1"
|
||||
|
||||
# cdk's registry nostr/nostr-sdk requirements are redirected onto the same
|
||||
# git repo coop already uses, keeping a single copy of each in the graph.
|
||||
[patch.crates-io]
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 571 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 209 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 209 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 341 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 522 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,16 @@
|
||||
<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="M15 10V9" />
|
||||
<path d="M7.084 14.302a5.12 5.12 0 009.833 0 .24.24 0 00-.235-.302H7.32a.24.24 0 00-.235.302" />
|
||||
<path d="M9 10V9" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 390 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 604 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 576 B |
@@ -0,0 +1,14 @@
|
||||
<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"
|
||||
>
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 520 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 435 B |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 624 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 696 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 899 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M10.75 14.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.25 7.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.75 10.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M21.25 12.75V15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 5.25V3.75H7M11.25 5.25V3.75H7M7 3.75V12.25M7 12.25H5.25M7 12.25H8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 726 B |
@@ -0,0 +1,16 @@
|
||||
<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="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<line x1="19" x2="19" y1="8" y2="14" />
|
||||
<line x1="22" x2="16" y1="11" y2="11" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21.25 10.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V10.75M21.25 10.75H18.75M21.25 10.75V6.75C21.25 5.64543 20.3546 4.75 19.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V10.75M2.75 10.75H5.25M5.25 10.75H7.13177C7.53452 10.75 7.92789 10.8716 8.26039 11.0989L9.43406 11.9011C9.76655 12.1284 10.1599 12.25 10.5627 12.25H13.4373C13.8401 12.25 14.2334 12.1284 14.5659 11.9011L15.7396 11.0989C16.0721 10.8716 16.4655 10.75 16.8682 10.75H18.75M5.25 10.75V7.75C5.25 7.47386 5.47386 7.25 5.75 7.25H18.25C18.5261 7.25 18.75 7.47386 18.75 7.75V10.75" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 748 B |
@@ -0,0 +1,307 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Adventure",
|
||||
"author": "iTerm2-Color-Schemes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Adventure",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#25292d",
|
||||
"accent.foreground": "#feffff",
|
||||
"background": "#040404",
|
||||
"border": "#282828",
|
||||
"danger.background": "#d84a33",
|
||||
"foreground": "#feffff",
|
||||
"input.border": "#333333",
|
||||
"link.active.foreground": "#417AB3",
|
||||
"link.foreground": "#417AB3",
|
||||
"link.hover.foreground": "#417AB3",
|
||||
"list.active.background": "#5da60222",
|
||||
"list.active.border": "#5da602",
|
||||
"list.even.background": "#0e0e0e",
|
||||
"muted.background": "#171717",
|
||||
"muted.foreground": "#5d6165",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#040404",
|
||||
"popover.foreground": "#feffff",
|
||||
"primary.active.background": "#0265a799",
|
||||
"primary.background": "#4384ad",
|
||||
"primary.foreground": "#feffff",
|
||||
"primary.hover.background": "#417AB3",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#606060",
|
||||
"secondary.background": "#171a1c",
|
||||
"secondary.active.background": "#191d1e",
|
||||
"secondary.foreground": "#e4d5c7",
|
||||
"secondary.hover.background": "#1e2224",
|
||||
"tab.active.background": "#040404",
|
||||
"tab.active.foreground": "#feffff",
|
||||
"tab.background": "#04040400",
|
||||
"tab.foreground": "#677179",
|
||||
"tab_bar.background": "#040404",
|
||||
"table.background": "#040404",
|
||||
"table.head.foreground": "#feffffb3",
|
||||
"table.row.border": "#4b647970",
|
||||
"title_bar.background": "#0f1112",
|
||||
"title_bar.border": "#282828",
|
||||
"ring": "#5da602",
|
||||
"base.red": "#d84a33",
|
||||
"base.red.light": "#d76b42",
|
||||
"base.green": "#5da602",
|
||||
"base.green.light": "#99b52c",
|
||||
"base.blue": "#417ab3",
|
||||
"base.blue.light": "#97d7ef",
|
||||
"base.cyan": "#41b3a9",
|
||||
"base.cyan.light": "#97efd6",
|
||||
"base.magenta": "#882252",
|
||||
"base.magenta.light": "#e599bc",
|
||||
"base.yellow": "#aa7900",
|
||||
"base.yellow.light": "#ffb670"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#feffff",
|
||||
"editor.background": "#040404",
|
||||
"editor.active_line.background": "#0e0e0e",
|
||||
"editor.line_number": "#5d6165",
|
||||
"editor.active_line_number": "#feffff",
|
||||
"editor.invisible": "#5d616566",
|
||||
"conflict": "#d84a33",
|
||||
"created": "#5da602",
|
||||
"deleted": "#d84a33",
|
||||
"error": "#d84a33",
|
||||
"hidden": "#5d6165",
|
||||
"hint": "#99b52c",
|
||||
"ignored": "#5d6165",
|
||||
"info": "#417ab3",
|
||||
"modified": "#eebb6e",
|
||||
"predictive": "#5d6165",
|
||||
"renamed": "#5da602",
|
||||
"success": "#5da602",
|
||||
"unreachable": "#5d6165",
|
||||
"warning": "#eebb6e",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#5d6165",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#5d6165",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#d84a33"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#feffff"
|
||||
},
|
||||
"function": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#417ab3"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#97d7ef",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#4b6479",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#d84a33"
|
||||
},
|
||||
"string": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#d84a33"
|
||||
},
|
||||
"title": {
|
||||
"color": "#97d7ef",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"property": {
|
||||
"color": "#feffff"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#d84a33"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Adventure Time",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#36345f",
|
||||
"accent.foreground": "#C7C7D4",
|
||||
"background": "#1f1d45",
|
||||
"border": "#333150",
|
||||
"foreground": "#C7C7D4",
|
||||
"input.border": "#555465",
|
||||
"link.active.foreground": "#4b61bf",
|
||||
"link.foreground": "#5f72c6",
|
||||
"link.hover.foreground": "#8593d3",
|
||||
"list.active.background": "#4ab11822",
|
||||
"list.active.border": "#549235",
|
||||
"list.even.background": "#1c1a37",
|
||||
"muted.background": "#29274a",
|
||||
"muted.foreground": "#717192",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#1f1d45",
|
||||
"popover.foreground": "#C7C7D4",
|
||||
"primary.active.background": "#5f72c699",
|
||||
"primary.background": "#5f72c6",
|
||||
"primary.foreground": "#ffffff",
|
||||
"primary.hover.background": "#5f72c6aa",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#555465",
|
||||
"secondary.background": "#2e2c51",
|
||||
"secondary.active.background": "#36345f",
|
||||
"secondary.foreground": "#C7C7D4",
|
||||
"secondary.hover.background": "#34325b",
|
||||
"tab.active.background": "#1f1d45",
|
||||
"tab.active.foreground": "#C7C7D4",
|
||||
"tab.background": "#1f1d4500",
|
||||
"tab.foreground": "#aeab9e",
|
||||
"tab_bar.background": "#1f1d45",
|
||||
"table.active.background": "#4ab11811",
|
||||
"table.active.border": "#549235",
|
||||
"table.head.foreground": "#f8dcc0b3",
|
||||
"table.row.border": "#333333",
|
||||
"title_bar.background": "#1c1a3e",
|
||||
"title_bar.border": "#333150",
|
||||
"base.red": "#a02733",
|
||||
"base.red.light": "#fc5f5a",
|
||||
"base.green": "#549235",
|
||||
"base.green.light": "#9eff6e",
|
||||
"base.blue": "#2b53ab",
|
||||
"base.blue.light": "#1997c6",
|
||||
"base.cyan": "#26977b",
|
||||
"base.cyan.light": "#5afcd4",
|
||||
"base.magenta": "#665993",
|
||||
"base.magenta.light": "#9b5953",
|
||||
"base.yellow": "#ce7837",
|
||||
"base.yellow.light": "#efc11a"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#f8dcc0",
|
||||
"editor.background": "#1f1d45",
|
||||
"editor.active_line.background": "#36345f",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#5d616566",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Alduin",
|
||||
"author": "AlessandroYorba",
|
||||
"url": "https://github.com/AlessandroYorba/Alduin",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Alduin",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2e2b29",
|
||||
"accent.foreground": "#ebdbb2",
|
||||
"background": "#1C1C1C",
|
||||
"border": "#3a3a3a",
|
||||
"danger.background": "#cc241d",
|
||||
"danger.active.background": "#cc241d",
|
||||
"danger.hover.background": "#fb4934",
|
||||
"foreground": "#9E9E9E",
|
||||
"input.border": "#504945",
|
||||
"link.active.foreground": "#83a598",
|
||||
"link.foreground": "#83a598",
|
||||
"link.hover.foreground": "#83a598",
|
||||
"list.active.background": "#262626",
|
||||
"list.active.border": "#9E9E9E",
|
||||
"list.even.background": "#262626",
|
||||
"list.head.background": "#1C1C1C",
|
||||
"muted.background": "#262626",
|
||||
"muted.foreground": "#878787",
|
||||
"panel.background": "#282828",
|
||||
"primary.active.background": "#45858899",
|
||||
"primary.background": "#458588",
|
||||
"primary.foreground": "#F0F0F0",
|
||||
"primary.hover.background": "#458588AA",
|
||||
"scrollbar.background": "#28282800",
|
||||
"scrollbar.thumb.background": "#504945",
|
||||
"secondary.background": "#282828",
|
||||
"secondary.active.background": "#35322f",
|
||||
"secondary.foreground": "#9E9E9E",
|
||||
"secondary.hover.background": "#36323099",
|
||||
"tab.active.background": "#1C1C1C",
|
||||
"tab.active.foreground": "#ebdbb2",
|
||||
"tab.background": "#14141400",
|
||||
"tab.foreground": "#848382",
|
||||
"table.row.border": "#50494570",
|
||||
"title_bar.background": "#262626",
|
||||
"title_bar.border": "#3a3a3a",
|
||||
"ring": "#9E9E9E",
|
||||
"base.blue": "#87afaf",
|
||||
"base.cyan": "#878787",
|
||||
"base.green": "#7a875f",
|
||||
"base.magenta": "#af8787",
|
||||
"base.magenta.light": "#af878788",
|
||||
"base.red": "#8b5f61",
|
||||
"base.yellow": "#9d906c"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#9E9E9E66",
|
||||
"conflict": "#8b5f61",
|
||||
"created": "#87afaf",
|
||||
"error": "#8b5f61",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#af8787",
|
||||
"info": "#878787",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Asciinema",
|
||||
"author": "asciinema.org",
|
||||
"url": "https://asciinema.org",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Asciinema",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#262829",
|
||||
"accent.foreground": "#cccccc",
|
||||
"background": "#121314",
|
||||
"border": "#303030",
|
||||
"danger.background": "#dd3c69",
|
||||
"danger.active.background": "#dd3c69",
|
||||
"danger.hover.background": "#e94f7a",
|
||||
"foreground": "#cccccc",
|
||||
"input.border": "#3a3a3a",
|
||||
"link.active.foreground": "#26b0d7",
|
||||
"link.foreground": "#26b0d7",
|
||||
"link.hover.foreground": "#3cc0e7",
|
||||
"list.active.background": "#1a1b1c",
|
||||
"list.active.border": "#cccccc",
|
||||
"list.even.background": "#1a1b1c",
|
||||
"list.head.background": "#121314",
|
||||
"muted.background": "#1a1b1c",
|
||||
"muted.foreground": "#6d6d6d",
|
||||
"panel.background": "#181919",
|
||||
"primary.active.background": "#26b0d799",
|
||||
"primary.background": "#26b0d7",
|
||||
"primary.foreground": "#ffffff",
|
||||
"primary.hover.background": "#26b0d7AA",
|
||||
"scrollbar.background": "#12131400",
|
||||
"scrollbar.thumb.background": "#2a2a2a",
|
||||
"secondary.background": "#181919",
|
||||
"secondary.active.background": "#1f2020",
|
||||
"secondary.foreground": "#cccccc",
|
||||
"secondary.hover.background": "#1f202099",
|
||||
"tab.active.background": "#121314",
|
||||
"tab.active.foreground": "#cccccc",
|
||||
"tab.background": "#12131400",
|
||||
"tab.foreground": "#6d6d6d",
|
||||
"table.row.border": "#2a2a2a70",
|
||||
"title_bar.background": "#181919",
|
||||
"title_bar.border": "#303030",
|
||||
"ring": "#5d5d5d",
|
||||
"base.blue": "#26b0d7",
|
||||
"base.cyan": "#54e1b9",
|
||||
"base.green": "#4ebf22",
|
||||
"base.magenta": "#b954e1",
|
||||
"base.red": "#dd3c69",
|
||||
"base.yellow": "#ddaf3c"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#cccccc",
|
||||
"editor.background": "#121314",
|
||||
"editor.active_line.background": "#1a1b1c",
|
||||
"editor.line_number": "#6d6d6d",
|
||||
"editor.active_line_number": "#cccccc",
|
||||
"editor.invisible": "#6d6d6d66",
|
||||
"conflict": "#dd3c69",
|
||||
"created": "#4ebf22",
|
||||
"error": "#dd3c69",
|
||||
"hidden": "#6d6d6d",
|
||||
"hint": "#b954e1",
|
||||
"info": "#26b0d7",
|
||||
"modified": "#ddaf3c",
|
||||
"predictive": "#2a2a2a",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#ddaf3c"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#b954e1"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#5d5d5d"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#5d5d5d"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#b954e1"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#26b0d7"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#cccccc"
|
||||
},
|
||||
"function": {
|
||||
"color": "#54e1b9"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#dd3c69"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#26b0d7",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#54e1b9",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#b954e1"
|
||||
},
|
||||
"string": {
|
||||
"color": "#4ebf22"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#54e1b9"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#4ebf22"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#ddaf3c"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#ddaf3c"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#dd3c69"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#cccccc"
|
||||
},
|
||||
"title": {
|
||||
"color": "#ddaf3c",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#26b0d7"
|
||||
},
|
||||
"property": {
|
||||
"color": "#cccccc"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#dd3c69"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Aurora",
|
||||
"author": "Longbridge",
|
||||
"url": "https://github.com/longbridge/gpui-kit",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Aurora Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#F1F5F9",
|
||||
"accent.foreground": "#0F172A",
|
||||
"accordion.background": "#FFFFFF",
|
||||
"accordion.hover.background": "#F8FAFC",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E2E8F0",
|
||||
"button.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
|
||||
"button.hover.background": "linear-gradient(180deg, #F8FAFC, #F1F5F9)",
|
||||
"button.active.background": "linear-gradient(180deg, #F1F5F9, #E2E8F0)",
|
||||
"button.foreground": "#0F172A",
|
||||
"button.primary.background": "linear-gradient(180deg, #1E293B, #0F172A)",
|
||||
"button.primary.hover.background": "linear-gradient(180deg, #334155, #1E293B)",
|
||||
"button.primary.active.background": "linear-gradient(180deg, #0F172A, #020617)",
|
||||
"button.primary.foreground": "#F8FAFC",
|
||||
"button.secondary.background": "linear-gradient(180deg, #F1F5F9, #E2E8F0)",
|
||||
"button.secondary.hover.background": "linear-gradient(180deg, #E2E8F0, #CBD5E1)",
|
||||
"button.secondary.active.background": "linear-gradient(180deg, #CBD5E1, #94A3B8)",
|
||||
"button.secondary.foreground": "#0F172A",
|
||||
"button.danger.background": "linear-gradient(180deg, #FEF2F2, #FEE2E2)",
|
||||
"button.danger.hover.background": "linear-gradient(180deg, #FEE2E2, #FECACA)",
|
||||
"button.danger.active.background": "linear-gradient(180deg, #FECACA, #FCA5A5)",
|
||||
"button.danger.foreground": "#B91C1C",
|
||||
"button.info.background": "linear-gradient(180deg, #ECFEFF, #CFFAFE)",
|
||||
"button.info.hover.background": "linear-gradient(180deg, #CFFAFE, #A5F3FC)",
|
||||
"button.info.active.background": "linear-gradient(180deg, #A5F3FC, #67E8F9)",
|
||||
"button.info.foreground": "#0E7490",
|
||||
"button.success.background": "linear-gradient(180deg, #F0FDF4, #DCFCE7)",
|
||||
"button.success.hover.background": "linear-gradient(180deg, #DCFCE7, #BBF7D0)",
|
||||
"button.success.active.background": "linear-gradient(180deg, #BBF7D0, #86EFAC)",
|
||||
"button.success.foreground": "#15803D",
|
||||
"button.warning.background": "linear-gradient(180deg, #FEFCE8, #FEF3C7)",
|
||||
"button.warning.hover.background": "linear-gradient(180deg, #FEF3C7, #FDE68A)",
|
||||
"button.warning.active.background": "linear-gradient(180deg, #FDE68A, #FCD34D)",
|
||||
"button.warning.foreground": "#A16207",
|
||||
"caret": "#0F172A",
|
||||
"chart.1": "#93C5FD",
|
||||
"chart.2": "#3B82F6",
|
||||
"chart.3": "#2563EB",
|
||||
"chart.4": "#1D4ED8",
|
||||
"chart.5": "#1E40AF",
|
||||
"chart.bullish": "green-600",
|
||||
"chart.bearish": "red-600",
|
||||
"danger.background": "linear-gradient(180deg, #EF4444, #DC2626)",
|
||||
"danger.active.background": "linear-gradient(180deg, #DC2626, #B91C1C)",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"danger.hover.background": "linear-gradient(180deg, #F87171, #EF4444)",
|
||||
"description_list.label.background": "#F8FAFC",
|
||||
"description_list.label.foreground": "#0F172A",
|
||||
"drag.border": "#3B82F6",
|
||||
"drop_target.background": "#3B82F640",
|
||||
"foreground": "#020617",
|
||||
"group_box.background": "#F8FAFC",
|
||||
"group_box.foreground": "#0F172A",
|
||||
"info.background": "linear-gradient(180deg, #06B6D4, #0891B2)",
|
||||
"info.active.background": "linear-gradient(180deg, #0891B2, #0E7490)",
|
||||
"info.foreground": "#FFFFFF",
|
||||
"info.hover.background": "linear-gradient(180deg, #22D3EE, #06B6D4)",
|
||||
"input.border": "#E2E8F0",
|
||||
"link": "#0F172A",
|
||||
"link.active": "#020617",
|
||||
"link.hover": "#334155",
|
||||
"list.background": "#FFFFFF",
|
||||
"list.active.background": "#BFDBFE33",
|
||||
"list.active.border": "#60A5FA",
|
||||
"list.even.background": "#F8FAFC",
|
||||
"list.head.background": "#F8FAFC",
|
||||
"list.hover.background": "#F1F5F9",
|
||||
"muted.background": "#F1F5F9",
|
||||
"muted.foreground": "#64748B",
|
||||
"popover.background": "#FFFFFF",
|
||||
"popover.foreground": "#020617",
|
||||
"primary.background": "linear-gradient(180deg, #1E293B, #0F172A)",
|
||||
"primary.active.background": "linear-gradient(180deg, #0F172A, #020617)",
|
||||
"primary.foreground": "#F8FAFC",
|
||||
"primary.hover.background": "linear-gradient(180deg, #334155, #1E293B)",
|
||||
"progress.bar.background": "linear-gradient(180deg, #1E293B, #0F172A)",
|
||||
"ring": "#0F172A",
|
||||
"scrollbar.background": "#F8FAFC00",
|
||||
"scrollbar.thumb.background": "linear-gradient(to right, #CBD5E173, #94A3B873)",
|
||||
"scrollbar.thumb.hover.background": "linear-gradient(to right, #CBD5E199, #94A3B899)",
|
||||
"secondary.background": "#E2E8F0",
|
||||
"secondary.active.background": "#CBD5E1",
|
||||
"secondary.foreground": "#0F172A",
|
||||
"secondary.hover.background": "#E2E8F0",
|
||||
"selection.background": "#55A0FC",
|
||||
"sidebar.background": "#F8FAFC",
|
||||
"sidebar.accent.background": "#E2E8F0",
|
||||
"sidebar.accent.foreground": "#0F172A",
|
||||
"sidebar.border": "#E2E8F0",
|
||||
"sidebar.foreground": "#0F172A",
|
||||
"sidebar.primary.background": "#0F172A",
|
||||
"sidebar.primary.foreground": "#F8FAFC",
|
||||
"skeleton.background": "#F1F5F9",
|
||||
"slider.background": "#0F172A",
|
||||
"slider.thumb.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
|
||||
"success.background": "linear-gradient(180deg, #22C55E, #16A34A)",
|
||||
"success.active.background": "linear-gradient(180deg, #16A34A, #15803D)",
|
||||
"success.foreground": "#FFFFFF",
|
||||
"success.hover.background": "linear-gradient(180deg, #4ADE80, #22C55E)",
|
||||
"switch.background": "linear-gradient(180deg, #E2E8F0, #CBD5E1)",
|
||||
"switch.thumb.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
|
||||
"tab.background": "#00000000",
|
||||
"tab.active.background": "#FFFFFF",
|
||||
"tab.active.foreground": "#0F172A",
|
||||
"tab.foreground": "#334155",
|
||||
"tab_bar.background": "#F1F5F9",
|
||||
"tab_bar.segmented.background": "#F1F5F9",
|
||||
"table.background": "#FFFFFF",
|
||||
"table.active.background": "#BFDBFE33",
|
||||
"table.active.border": "#60A5FA",
|
||||
"table.even.background": "#F8FAFC",
|
||||
"table.head.background": "#F8FAFC",
|
||||
"table.head.foreground": "#64748B",
|
||||
"table.foot.background": "#F8FAFC",
|
||||
"table.foot.foreground": "#64748B",
|
||||
"table.hover.background": "#F1F5F9",
|
||||
"table.row.border": "#E2E8F0B3",
|
||||
"title_bar.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
|
||||
"title_bar.border": "#E2E8F0",
|
||||
"status_bar.background": "linear-gradient(180deg, #F8FAFC, #F1F5F9)",
|
||||
"status_bar.border": "#E2E8F0",
|
||||
"warning.background": "linear-gradient(180deg, #EAB308, #CA8A04)",
|
||||
"warning.active.background": "linear-gradient(180deg, #CA8A04, #A16207)",
|
||||
"warning.hover.background": "linear-gradient(180deg, #FACC15, #EAB308)",
|
||||
"warning.foreground": "#422006",
|
||||
"overlay": "#0000000D",
|
||||
"window.border": "#E2E8F0",
|
||||
"base.red": "red-600",
|
||||
"base.red.light": "red-400",
|
||||
"base.green": "green-600",
|
||||
"base.green.light": "green-400",
|
||||
"base.blue": "blue-600",
|
||||
"base.blue.light": "blue-400",
|
||||
"base.yellow": "yellow-600",
|
||||
"base.yellow.light": "yellow-400",
|
||||
"base.magenta": "purple-600",
|
||||
"base.magenta.light": "purple-400",
|
||||
"base.cyan": "cyan-600",
|
||||
"base.cyan.light": "cyan-400"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#020617",
|
||||
"editor.background": "#FFFFFF",
|
||||
"editor.active_line.background": "#F8FAFC",
|
||||
"editor.line_number": "#94A3B8",
|
||||
"editor.active_line_number": "#0F172A",
|
||||
"editor.invisible": "#64748B66",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#957931"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"emphasis": {
|
||||
"font_style": "italic"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"font_weight": 700
|
||||
},
|
||||
"function": {
|
||||
"color": "#0000A2"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#0000A2",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6A7293",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#6F42C1"
|
||||
},
|
||||
"text.code.span": {
|
||||
"color": "#6F42C1"
|
||||
},
|
||||
"title": {
|
||||
"color": "#0433FF"
|
||||
},
|
||||
"type": {
|
||||
"color": "#6f42c1"
|
||||
},
|
||||
"property": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#C5060B"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Ayu",
|
||||
"author": "Zed Industries",
|
||||
"url": "https://github.com/zed-industries/zed/blob/e62dd2a0e584154886ff86cc1e9e3e060558b977/assets/themes/ayu",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Ayu Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#dadadc",
|
||||
"accent.foreground": "#5C6773",
|
||||
"background": "#FCFCFC",
|
||||
"foreground": "#5c6166",
|
||||
"border": "#cfd1d2ff",
|
||||
"ring": "#FF9940",
|
||||
"input.border": "#D9DCE3",
|
||||
"list.active.background": "#55B4D422",
|
||||
"list.active.border": "#55B4D4",
|
||||
"list.even.background": "#E6E6E6",
|
||||
"list.head.background": "#F4F4F599",
|
||||
"muted.background": "#F4F4F5",
|
||||
"muted.foreground": "#99a0a6",
|
||||
"panel.background": "#F3F4F5",
|
||||
"popover.background": "#ECECED",
|
||||
"popover.foreground": "#5C6773",
|
||||
"primary.active.background": "#42accf",
|
||||
"primary.background": "#55b4d3",
|
||||
"primary.foreground": "#FAFAFA",
|
||||
"primary.hover.background": "#5fb9d7",
|
||||
"scrollbar.background": "#FAFAFA00",
|
||||
"scrollbar.thumb.background": "#5c61664c",
|
||||
"secondary.active.background": "#CECECE",
|
||||
"secondary.background": "#ECECED",
|
||||
"secondary.foreground": "#5C6773",
|
||||
"secondary.hover.background": "#e0e0e0",
|
||||
"tab.active.background": "#FCFCFC",
|
||||
"tab.active.foreground": "#5C6773",
|
||||
"tab.background": "#ECECED00",
|
||||
"tab_bar.background": "#F4F4F5",
|
||||
"title_bar.background": "#ECECED",
|
||||
"title_bar.border": "#CFD1D2",
|
||||
"base.yellow": "#F1AD49",
|
||||
"base.red": "#F07171",
|
||||
"base.blue": "#55b4d3",
|
||||
"base.green": "#85b304",
|
||||
"base.magenta": "#9371f0",
|
||||
"base.cyan": "#4dbf99"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#5C6773",
|
||||
"editor.background": "#FCFCFC",
|
||||
"editor.active_line.background": "#ECECED",
|
||||
"editor.line_number": "#ABB0B6",
|
||||
"editor.active_line_number": "#5C6773",
|
||||
"editor.invisible": "#73777b66",
|
||||
"conflict": "#f1ad49ff",
|
||||
"conflict.background": "#ffeedaff",
|
||||
"conflict.border": "#ffe1beff",
|
||||
"created": "#85b304ff",
|
||||
"created.background": "#e9efd2ff",
|
||||
"created.border": "#d7e3aeff",
|
||||
"deleted": "#ef7271ff",
|
||||
"deleted.background": "#ffe3e1ff",
|
||||
"deleted.border": "#ffcdcaff",
|
||||
"error": "#ef7271ff",
|
||||
"error.background": "#ffe3e1ff",
|
||||
"error.border": "#ffcdcaff",
|
||||
"hidden": "#a9acaeff",
|
||||
"hidden.background": "#dcdddeff",
|
||||
"hidden.border": "#d5d6d8ff",
|
||||
"hint": "#8ca7c2ff",
|
||||
"hint.background": "#deebfaff",
|
||||
"hint.border": "#c4daf6ff",
|
||||
"ignored": "#a9acaeff",
|
||||
"ignored.background": "#dcdddeff",
|
||||
"ignored.border": "#cfd1d2ff",
|
||||
"info": "#3b9ee5ff",
|
||||
"info.background": "#deebfaff",
|
||||
"info.border": "#c4daf6ff",
|
||||
"modified": "#f1ad49ff",
|
||||
"modified.background": "#ffeedaff",
|
||||
"modified.border": "#ffe1beff",
|
||||
"predictive": "#9eb9d3ff",
|
||||
"predictive.background": "#e9efd2ff",
|
||||
"predictive.border": "#d7e3aeff",
|
||||
"renamed": "#3b9ee5ff",
|
||||
"renamed.background": "#deebfaff",
|
||||
"renamed.border": "#c4daf6ff",
|
||||
"success": "#85b304ff",
|
||||
"success.background": "#e9efd2ff",
|
||||
"success.border": "#d7e3aeff",
|
||||
"unreachable": "#8b8e92ff",
|
||||
"unreachable.background": "#dcdddeff",
|
||||
"unreachable.border": "#cfd1d2ff",
|
||||
"warning": "#f1ad49ff",
|
||||
"warning.background": "#ffeedaff",
|
||||
"warning.border": "#ffe1beff",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#898d90ff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#898d90ff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"emphasis": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"color": "#3b9ee5ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"enum": {
|
||||
"color": "#f98d3fff"
|
||||
},
|
||||
"function": {
|
||||
"color": "#f2ad48ff"
|
||||
},
|
||||
"hint": {
|
||||
"color": "#8ca7c2ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#fa8d3eff"
|
||||
},
|
||||
"label": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#f98d3fff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#85b304ff"
|
||||
},
|
||||
"namespace": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"number": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"operator": {
|
||||
"color": "#ed9365ff"
|
||||
},
|
||||
"predictive": {
|
||||
"color": "#9eb9d3ff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"preproc": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"primary": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"property": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"punctuation": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.bracket": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.delimiter": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.list_marker": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"selector": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"selector.pseudo": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#86b300ff"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#898d90ff"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#4bbf98ff"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e6ba7eff"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#f98d3fff"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#f98d3fff"
|
||||
},
|
||||
"title": {
|
||||
"color": "#5c6166ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"type": {
|
||||
"color": "#389ee6ff"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"variant": {
|
||||
"color": "#3b9ee5ff"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Ayu Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#20242b",
|
||||
"accent.foreground": "#B3B1AD",
|
||||
"background": "#0D1016",
|
||||
"border": "#292a2c",
|
||||
"ring": "#FFB454",
|
||||
"foreground": "#B3B1AD",
|
||||
"input.border": "#2D2F34",
|
||||
"list.active.background": "#36A3D922",
|
||||
"list.active.border": "#36A3D9",
|
||||
"list.even.background": "#191F2A99",
|
||||
"muted.background": "#16191F",
|
||||
"muted.foreground": "#52514f",
|
||||
"popover.background": "#0D1016",
|
||||
"popover.foreground": "#B3B1AD",
|
||||
"primary.active.background": "#36A3D9",
|
||||
"primary.background": "#5ac1fe",
|
||||
"primary.foreground": "#1F2430",
|
||||
"primary.hover.background": "#3DAEE9",
|
||||
"scrollbar.background": "#0A0E1400",
|
||||
"scrollbar.thumb.background": "#bfbdb64c",
|
||||
"secondary.active.background": "#26282f",
|
||||
"secondary.background": "#1F2127",
|
||||
"secondary.foreground": "#B3B1AD",
|
||||
"secondary.hover.background": "#26282f99",
|
||||
"tab.active.foreground": "#B3B1AD",
|
||||
"tab.active.background": "#0D1016",
|
||||
"tab_bar.background": "#16191F",
|
||||
"title_bar.background": "#16191F",
|
||||
"title_bar.border": "#292a2c",
|
||||
"base.yellow": "#FEB454",
|
||||
"base.red": "#ef7177",
|
||||
"base.blue": "#5ac1fe",
|
||||
"base.green": "#aad84c",
|
||||
"base.magenta": "#d2a6ff",
|
||||
"base.cyan": "#5a728b"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#bfbdb6",
|
||||
"editor.background": "#0d1016",
|
||||
"editor.active_line.background": "#1f2127bf",
|
||||
"editor.line_number": "#4b4c4e",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#73777b66",
|
||||
"conflict": "#feb454ff",
|
||||
"conflict.background": "#572815ff",
|
||||
"conflict.border": "#754221ff",
|
||||
"created": "#aad84cff",
|
||||
"created.background": "#294113ff",
|
||||
"created.border": "#405c1cff",
|
||||
"deleted": "#ef7177ff",
|
||||
"deleted.background": "#48161bff",
|
||||
"deleted.border": "#66272dff",
|
||||
"error": "#ef7177ff",
|
||||
"error.background": "#48161bff",
|
||||
"error.border": "#66272dff",
|
||||
"hidden": "#696a6aff",
|
||||
"hidden.background": "#313337ff",
|
||||
"hidden.border": "#383a3eff",
|
||||
"hint": "#628b80ff",
|
||||
"hint.background": "#0d2f4eff",
|
||||
"hint.border": "#1b4a6eff",
|
||||
"ignored": "#696a6aff",
|
||||
"ignored.background": "#313337ff",
|
||||
"ignored.border": "#3f4043ff",
|
||||
"info": "#5ac1feff",
|
||||
"info.background": "#0d2f4eff",
|
||||
"info.border": "#1b4a6eff",
|
||||
"modified": "#feb454ff",
|
||||
"modified.background": "#572815ff",
|
||||
"modified.border": "#754221ff",
|
||||
"predictive": "#5a728bff",
|
||||
"predictive.background": "#294113ff",
|
||||
"predictive.border": "#405c1cff",
|
||||
"renamed": "#5ac1feff",
|
||||
"renamed.background": "#0d2f4eff",
|
||||
"renamed.border": "#1b4a6eff",
|
||||
"success": "#aad84cff",
|
||||
"success.background": "#294113ff",
|
||||
"success.border": "#405c1cff",
|
||||
"unreachable": "#8a8986ff",
|
||||
"unreachable.background": "#313337ff",
|
||||
"unreachable.border": "#3f4043ff",
|
||||
"warning": "#feb454ff",
|
||||
"warning.background": "#572815ff",
|
||||
"warning.border": "#754221ff",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#8c8b88ff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#8c8b88ff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"emphasis": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"color": "#5ac1feff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"enum": {
|
||||
"color": "#fe8f40ff"
|
||||
},
|
||||
"function": {
|
||||
"color": "#ffb353ff"
|
||||
},
|
||||
"hint": {
|
||||
"color": "#628b80ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#ff8f3fff"
|
||||
},
|
||||
"label": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#fe8f40ff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#aad84cff"
|
||||
},
|
||||
"namespace": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"number": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"operator": {
|
||||
"color": "#f29668ff"
|
||||
},
|
||||
"predictive": {
|
||||
"color": "#5a728bff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"preproc": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"primary": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"property": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"punctuation": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.bracket": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.delimiter": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.list_marker": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"selector": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"selector.pseudo": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a9d94bff"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#8c8b88ff"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#95e6cbff"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e5b572ff"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#fe8f40ff"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#fe8f40ff"
|
||||
},
|
||||
"title": {
|
||||
"color": "#bfbdb6ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"type": {
|
||||
"color": "#59c2ffff"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"variant": {
|
||||
"color": "#5ac1feff"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Catppuccin",
|
||||
"author": "Catppuccino",
|
||||
"url": "https://github.com/catppuccin/catppuccin",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Catppuccin Latte",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#d3d8e0",
|
||||
"accent.foreground": "#4c4f69",
|
||||
"background": "#E5E9EF",
|
||||
"border": "#CCD0DA",
|
||||
"ring": "#7287fd",
|
||||
"foreground": "#4c4f69",
|
||||
"input.border": "#acb0be",
|
||||
"link.active.foreground": "#7287fd",
|
||||
"link.foreground": "#7287fd",
|
||||
"link.hover.foreground": "#7287fd",
|
||||
"list.active.background": "#7287fd22",
|
||||
"list.active.border": "#7287fd",
|
||||
"list.even.background": "#EFF1F5",
|
||||
"list.head.background": "#dce0e8",
|
||||
"muted.background": "#dce0e8",
|
||||
"muted.foreground": "#9a9db2",
|
||||
"panel.background": "#dce0e8",
|
||||
"primary.active.background": "#7287fd",
|
||||
"primary.background": "#7287fd",
|
||||
"primary.foreground": "#EFF1F5",
|
||||
"scrollbar.background": "#EFF1F500",
|
||||
"scrollbar.thumb.background": "#acb0be",
|
||||
"secondary.active.background": "#CCD2DE",
|
||||
"secondary.background": "#dce0e8",
|
||||
"secondary.foreground": "#4c4f69",
|
||||
"secondary.hover.background": "#CCD2DE99",
|
||||
"tab.active.background": "#E5E9EF",
|
||||
"tab.active.foreground": "#4c4f69",
|
||||
"tab.background": "#D2D7E200",
|
||||
"tab.foreground": "#82848c",
|
||||
"tab_bar.background": "#DCE0E8",
|
||||
"title_bar.background": "#DCE0E8",
|
||||
"title_bar.border": "#bec3d0",
|
||||
"base.red": "#d26a53",
|
||||
"base.green": "#5aa93b",
|
||||
"base.yellow": "#df8e1d",
|
||||
"base.blue": "#78acdc",
|
||||
"base.magenta": "#8778dc",
|
||||
"base.magenta.light": "#9978dc66",
|
||||
"base.cyan": "#53d2b0"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#4c4f69",
|
||||
"editor.background": "#EFF1F5",
|
||||
"editor.active_line.background": "#dce0e8",
|
||||
"editor.line_number": "#9a9db2",
|
||||
"editor.active_line_number": "#4c4f69",
|
||||
"editor.invisible": "#7c7f9366",
|
||||
"conflict": "#d20f39",
|
||||
"created": "#85dc78",
|
||||
"deleted": "#dc8a78",
|
||||
"error": "#d20f39",
|
||||
"hidden": "#9a9db2",
|
||||
"hint": "#7287fd",
|
||||
"ignored": "#acb0be",
|
||||
"info": "#1e66f5",
|
||||
"modified": "#df8e1d",
|
||||
"predictive": "#9a9db2",
|
||||
"renamed": "#9978dc",
|
||||
"success": "#85dc78",
|
||||
"unreachable": "#acb0be",
|
||||
"warning": "#df8e1d",
|
||||
"syntax": {
|
||||
"variable": {
|
||||
"color": "#4c4f69"
|
||||
},
|
||||
"variable.builtin": {
|
||||
"color": "#d20f39"
|
||||
},
|
||||
"variable.parameter": {
|
||||
"color": "#e64553"
|
||||
},
|
||||
"variable.member": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#d20f39",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"constant.builtin": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"constant.macro": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"module": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"label": {
|
||||
"color": "#209fb5"
|
||||
},
|
||||
"string": {
|
||||
"color": "#85dc78"
|
||||
},
|
||||
"string.documentation": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"string.regexp": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"string.special.path": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#dd7878"
|
||||
},
|
||||
"string.special.url": {
|
||||
"color": "#dc8a78",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"character": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"character.special": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"number": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"number.float": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"type": {
|
||||
"color": "#df8e1d"
|
||||
},
|
||||
"type.builtin": {
|
||||
"color": "#8839ef",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"type.definition": {
|
||||
"color": "#df8e1d"
|
||||
},
|
||||
"type.interface": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"type.super": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"attribute": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"property": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"function.builtin": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"function.call": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"function.macro": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"function.method": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"function.method.call": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#dd7878"
|
||||
},
|
||||
"operator": {
|
||||
"color": "#04a5e5"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.modifier": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.type": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.coroutine": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.function": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.operator": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.import": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.repeat": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.return": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.debug": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.exception": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.conditional": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.conditional.ternary": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.directive": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"keyword.directive.define": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"keyword.export": {
|
||||
"color": "#04a5e5"
|
||||
},
|
||||
"punctuation": {
|
||||
"color": "#7c7f93"
|
||||
},
|
||||
"punctuation.delimiter": {
|
||||
"color": "#7c7f93"
|
||||
},
|
||||
"punctuation.bracket": {
|
||||
"color": "#7c7f93"
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"punctuation.special.symbol": {
|
||||
"color": "#dd7878"
|
||||
},
|
||||
"punctuation.list_marker": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#7c7f93",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#7c7f93",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.documentation": {
|
||||
"color": "#7c7f93",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.error": {
|
||||
"color": "#d20f39",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.warning": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.hint": {
|
||||
"color": "#1e66f5",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.todo": {
|
||||
"color": "#dd7878",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.note": {
|
||||
"color": "#dc8a78",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"diff.plus": {
|
||||
"color": "#40a02b"
|
||||
},
|
||||
"diff.minus": {
|
||||
"color": "#d20f39"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"tag.attribute": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"tag.delimiter": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"parameter": {
|
||||
"color": "#e64553"
|
||||
},
|
||||
"field": {
|
||||
"color": "#7287fd"
|
||||
},
|
||||
"namespace": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"float": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"symbol": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"text": {
|
||||
"color": "#4c4f69"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"color": "#e64553",
|
||||
"font_weight": 700
|
||||
},
|
||||
"emphasis": {
|
||||
"color": "#e64553",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#e64553"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#40a02b"
|
||||
},
|
||||
"concept": {
|
||||
"color": "#209fb5"
|
||||
},
|
||||
"enum": {
|
||||
"color": "#179299",
|
||||
"font_weight": 700
|
||||
},
|
||||
"function.decorator": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"type.class.definition": {
|
||||
"color": "#df8e1d",
|
||||
"font_weight": 700
|
||||
},
|
||||
"hint": {
|
||||
"color": "#acb0be",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#7287fd"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#1e66f5",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"parent": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"predictive": {
|
||||
"color": "#9ca0b0"
|
||||
},
|
||||
"predoc": {
|
||||
"color": "#d20f39"
|
||||
},
|
||||
"primary": {
|
||||
"color": "#e64553"
|
||||
},
|
||||
"tag.doctype": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"string.doc": {
|
||||
"color": "#179299",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"title": {
|
||||
"color": "#4c4f69",
|
||||
"font_weight": 800
|
||||
},
|
||||
"variant": {
|
||||
"color": "#d20f39"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Catppuccin Frappe",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#3b3f4f",
|
||||
"accent.foreground": "#c6d0f5",
|
||||
"background": "#232634",
|
||||
"border": "#3e4255",
|
||||
"ring": "#f2d5cf",
|
||||
"foreground": "#c6d0f5",
|
||||
"input.border": "#51576d",
|
||||
"list.active.background": "#8caaee15",
|
||||
"list.even.background": "#303446",
|
||||
"list.head.background": "#2A2C3C",
|
||||
"muted.background": "#2c303f",
|
||||
"muted.foreground": "#979db5",
|
||||
"panel.background": "#414559",
|
||||
"popover.background": "#1E202B",
|
||||
"popover.foreground": "#c6d0f5",
|
||||
"primary.active.background": "#8caaee",
|
||||
"primary.background": "#8caaee",
|
||||
"primary.foreground": "#232634",
|
||||
"primary.hover.background": "#8caaee",
|
||||
"scrollbar.background": "#30344600",
|
||||
"scrollbar.thumb.background": "#51576d",
|
||||
"secondary.active.background": "#313445",
|
||||
"secondary.background": "#414559",
|
||||
"secondary.foreground": "#c6d0f5",
|
||||
"secondary.hover.background": "#31344599",
|
||||
"tab.active.background": "#232634",
|
||||
"tab.active.foreground": "#dce2f9",
|
||||
"tab.background": "#1E202B00",
|
||||
"tab.foreground": "#abb5d9",
|
||||
"tab_bar.background": "#1E202B",
|
||||
"title_bar.background": "#1D202B",
|
||||
"title_bar.border": "#30354b",
|
||||
"base.red": "#e78284",
|
||||
"base.green": "#a6d189",
|
||||
"base.yellow": "#e7d682",
|
||||
"base.blue": "#8caaee",
|
||||
"base.magenta": "#9882e7",
|
||||
"base.magenta.light": "#9882e766",
|
||||
"base.cyan": "#81c8be"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#c6d0f5",
|
||||
"editor.background": "#232634",
|
||||
"editor.active_line.background": "#41455977",
|
||||
"editor.line_number": "#979db5",
|
||||
"editor.active_line_number": "#c6d0f5",
|
||||
"editor.invisible": "#7c7f9366",
|
||||
"conflict": "#e78284",
|
||||
"created": "#a6d189",
|
||||
"deleted": "#3b2e2e",
|
||||
"error": "#3b2e2e",
|
||||
"hidden": "#51576d",
|
||||
"hint": "#8caaee",
|
||||
"info": "#1e2b4d",
|
||||
"modified": "#e7d682",
|
||||
"predictive": "#51576d",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#e7d682"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#e78284"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#51576d"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#51576d"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#e7d682"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#a6d189"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#c6d0f5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#8caaee"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e78284"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#8caaee",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#51576d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e78284"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a6d189"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#a6d189"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#a6d189"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e7d682"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#e7d682"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#8caaee"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c6d0f5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#e78284",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#8caaee"
|
||||
},
|
||||
"property": {
|
||||
"color": "#c6d0f5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e78284"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Catppuccin Macchiato",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#30344d",
|
||||
"accent.foreground": "#cad3f5",
|
||||
"action_bar.background": "#1E2030",
|
||||
"background": "#1E2030",
|
||||
"border": "#494d64",
|
||||
"ring": "#f4dbd6",
|
||||
"danger.background": "#ed8796",
|
||||
"foreground": "#cad3f5",
|
||||
"input.border": "#494d64",
|
||||
"link.active.foreground": "#8aadf4",
|
||||
"link.foreground": "#8aadf4",
|
||||
"link.hover.foreground": "#8aadf4",
|
||||
"list.active.background": "#8aadf420",
|
||||
"list.active.border": "#8aadf4",
|
||||
"list.even.background": "#24273a",
|
||||
"list.head.background": "#363a4f33",
|
||||
"muted.background": "#282a3a",
|
||||
"muted.foreground": "#b8c0e0",
|
||||
"popover.foreground": "#cad3f5",
|
||||
"primary.active.background": "#8aadf499",
|
||||
"primary.background": "#8aadf4",
|
||||
"primary.foreground": "#24273a",
|
||||
"primary.hover.background": "#8aadf4",
|
||||
"scrollbar.background": "#24273a00",
|
||||
"scrollbar.thumb.background": "#494d64",
|
||||
"secondary.active.background": "#3a3f55",
|
||||
"secondary.background": "#363a4f",
|
||||
"secondary.foreground": "#cad3f5",
|
||||
"secondary.hover.background": "#24273a99",
|
||||
"tab.background": "#17192600",
|
||||
"tab.foreground": "#aeb8db",
|
||||
"tab_bar.background": "#171926",
|
||||
"title_bar.background": "#171926",
|
||||
"title_bar.border": "#363A4F",
|
||||
"base.red": "#ed8796",
|
||||
"base.green": "#a6da95",
|
||||
"base.yellow": "#eed49f",
|
||||
"base.blue": "#8aadf4",
|
||||
"base.magenta": "#f5bde6",
|
||||
"base.magenta.light": "#f5bde666",
|
||||
"base.cyan": "#8bd5ca"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#cad3f5",
|
||||
"editor.background": "#1E2030",
|
||||
"editor.active_line.background": "#363a4f",
|
||||
"editor.line_number": "#b8c0e0",
|
||||
"editor.active_line_number": "#cad3f5",
|
||||
"editor.invisible": "#7c7f9366",
|
||||
"conflict": "#ed8796",
|
||||
"created": "#a6da95",
|
||||
"deleted": "#ed8796",
|
||||
"error": "#ed8796",
|
||||
"hidden": "#b8c0e0",
|
||||
"hint": "#8aadf4",
|
||||
"ignored": "#494d64",
|
||||
"info": "#8aadf4",
|
||||
"modified": "#eed49f",
|
||||
"predictive": "#b8c0e0",
|
||||
"renamed": "#f5bde6",
|
||||
"success": "#a6da95",
|
||||
"unreachable": "#b8c0e0",
|
||||
"warning": "#eed49f",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#f5bde6"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#b8c0e0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#b8c0e0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#8aadf4"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#cad3f5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#8aadf4"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#f5bde6"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#8aadf4",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#b8c0e0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a6da95"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#a6da95"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#a6da95"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#8aadf4"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#cad3f5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#f5bde6",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#8aadf4"
|
||||
},
|
||||
"property": {
|
||||
"color": "#cad3f5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#f5bde6"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Catppuccin Mocha",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2e2e3e",
|
||||
"accent.foreground": "#cdd6f4",
|
||||
"background": "#181825",
|
||||
"border": "#313244",
|
||||
"ring": "#cba6f7",
|
||||
"danger.background": "#f38ba8",
|
||||
"danger.active.background": "#eba0ac",
|
||||
"danger.hover.background": "#f38ba8",
|
||||
"foreground": "#cdd6f4",
|
||||
"input.border": "#6c7086",
|
||||
"link.active.foreground": "#89b4fa",
|
||||
"link.foreground": "#89b4fa",
|
||||
"link.hover.foreground": "#89b4fa",
|
||||
"list.active.background": "#89b4fa15",
|
||||
"list.active.border": "#89b4fa77",
|
||||
"list.hover.background": "#202031",
|
||||
"list.even.background": "#161622",
|
||||
"list.head.background": "#1E1E2E",
|
||||
"muted.background": "#302d41",
|
||||
"muted.foreground": "#6c7086",
|
||||
"panel.background": "#302d41",
|
||||
"popover.background": "#1e1e2e",
|
||||
"popover.foreground": "#cdd6f4",
|
||||
"primary.active.background": "#89b4fa",
|
||||
"primary.background": "#89b4fa",
|
||||
"primary.foreground": "#1e1e2e",
|
||||
"primary.hover.background": "#74c7ec",
|
||||
"scrollbar.background": "#1e1e2e00",
|
||||
"scrollbar.thumb.background": "#4e4e5e",
|
||||
"secondary.active.background": "#45475a",
|
||||
"secondary.background": "#302d41",
|
||||
"secondary.foreground": "#cdd6f4",
|
||||
"secondary.hover.background": "#575268",
|
||||
"tab.background": "#0B0B1100",
|
||||
"tab.foreground": "#aeb7d5",
|
||||
"tab_bar.background": "#0B0B11",
|
||||
"title_bar.background": "#11111B",
|
||||
"title_bar.border": "#313244",
|
||||
"base.red": "#f38ba8",
|
||||
"base.green": "#a6e3a1",
|
||||
"base.yellow": "#f9e2af",
|
||||
"base.blue": "#89b4fa",
|
||||
"base.magenta": "#f5c2e7",
|
||||
"base.magenta.light": "#f38ba866",
|
||||
"base.cyan": "#94e2d5"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#cdd6f4",
|
||||
"editor.background": "#181825",
|
||||
"editor.active_line.background": "#222230AA",
|
||||
"editor.line_number": "#6c7086",
|
||||
"editor.active_line_number": "#cdd6f4",
|
||||
"editor.invisible": "#7c7f9366",
|
||||
"conflict": "#f38ba8",
|
||||
"created": "#a6e3a1",
|
||||
"deleted": "#f38ba8",
|
||||
"error": "#f38ba8",
|
||||
"hidden": "#6c7086",
|
||||
"hint": "#89b4fa",
|
||||
"ignored": "#6c7086",
|
||||
"info": "#89b4fa",
|
||||
"modified": "#f9e2af",
|
||||
"predictive": "#6c7086",
|
||||
"renamed": "#f5c2e7",
|
||||
"success": "#a6e3a1",
|
||||
"unreachable": "#6c7086",
|
||||
"warning": "#f9e2af",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#f5c2e7"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#6c7086",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#6c7086",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#89b4fa"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#cdd6f4"
|
||||
},
|
||||
"function": {
|
||||
"color": "#89b4fa"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#f5c2e7"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#89b4fa",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6c7086",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a6e3a1"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#a6e3a1"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#a6e3a1"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#89b4fa"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#cdd6f4"
|
||||
},
|
||||
"title": {
|
||||
"color": "#f5c2e7",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#89b4fa"
|
||||
},
|
||||
"property": {
|
||||
"color": "#cdd6f4"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#f5c2e7"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Everforest",
|
||||
"author": "sainnhe",
|
||||
"url": "https://github.com/sainnhe/everforest",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Everforest Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#E7E5D4",
|
||||
"accent.foreground": "#5F6D75",
|
||||
"background": "#FEFCEE",
|
||||
"border": "#E7E5D4",
|
||||
"danger.background": "#e67e80",
|
||||
"danger.foreground": "#ffffff",
|
||||
"foreground": "#5F6D75",
|
||||
"input.border": "#E7E5D4",
|
||||
"link.active.foreground": "#7fbbb3",
|
||||
"link.foreground": "#7fbbb3",
|
||||
"link.hover.foreground": "#7fbbb3",
|
||||
"list.active.background": "#a7c08025",
|
||||
"list.active.border": "#a7c080",
|
||||
"list.even.background": "#F4F1E2",
|
||||
"list.head.background": "#F4F1E2",
|
||||
"muted.background": "#f1f0e2",
|
||||
"muted.foreground": "#959a9d",
|
||||
"popover.background": "#FEFCEE",
|
||||
"popover.foreground": "#5F6D75",
|
||||
"primary.background": "#e69875",
|
||||
"primary.foreground": "#FEFCEE",
|
||||
"scrollbar.background": "#e0e0e000",
|
||||
"scrollbar.thumb.background": "#D7D5C4",
|
||||
"secondary.background": "#EEEADA",
|
||||
"secondary.foreground": "#5F6D75",
|
||||
"tab.active.background": "#FEFCEE",
|
||||
"tab.active.foreground": "#5F6D75",
|
||||
"tab.background": "#F4F1E200",
|
||||
"tab.foreground": "#6b7b84",
|
||||
"tab_bar.background": "#F4F1E2",
|
||||
"title_bar.background": "#F9F5E4",
|
||||
"title_bar.border": "#E7E5D4",
|
||||
"status_bar.background": "#F9F5E4",
|
||||
"base.red": "#e67e80",
|
||||
"base.green": "#a7c080",
|
||||
"base.yellow": "#dbbc7f",
|
||||
"base.blue": "#7fbbb3",
|
||||
"base.magenta": "#d699b6",
|
||||
"base.magenta.light": "#d699b699",
|
||||
"base.cyan": "#83c092"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#5F6D75",
|
||||
"editor.background": "#FEFCEE",
|
||||
"editor.active_line.background": "#E7E5D4",
|
||||
"editor.line_number": "#959a9d",
|
||||
"editor.active_line_number": "#5F6D75",
|
||||
"editor.invisible": "#959a9d66",
|
||||
"conflict": "#e67e80",
|
||||
"created": "#a7c080",
|
||||
"deleted": "#e67e80",
|
||||
"error": "#e67e80",
|
||||
"hidden": "#959a9d",
|
||||
"hint": "#7fbbb3",
|
||||
"ignored": "#959a9d",
|
||||
"info": "#7fbbb3",
|
||||
"modified": "#dbbc7f",
|
||||
"predictive": "#5F6D75",
|
||||
"renamed": "#a7c080",
|
||||
"success": "#a7c080",
|
||||
"unreachable": "#959a9d",
|
||||
"warning": "#dbbc7f",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#dbbc7f"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#e69875"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#959a9d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#959a9d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#e69875"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#5F6D75"
|
||||
},
|
||||
"function": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e67e80"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#7fbbb3",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#5F6D75",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e69875"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#dbbc7f"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#dbbc7f"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#dbbc7f"
|
||||
},
|
||||
"title": {
|
||||
"color": "#e69875",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#7fbbb3"
|
||||
},
|
||||
"property": {
|
||||
"color": "#5F6D75"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e67e80"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Everforest Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#3c4448",
|
||||
"accent.foreground": "#d3c6aa",
|
||||
"background": "#262E34",
|
||||
"border": "#40484c",
|
||||
"foreground": "#d3c6aa",
|
||||
"input.border": "#485156",
|
||||
"link.active.foreground": "#7fbbb3",
|
||||
"link.foreground": "#7fbbb3",
|
||||
"link.hover.foreground": "#7fbbb3",
|
||||
"list.active.background": "#a7c08022",
|
||||
"list.active.border": "#a7c08088",
|
||||
"list.even.background": "#2E383B",
|
||||
"list.head.background": "#2E383B",
|
||||
"muted.background": "#2E383B",
|
||||
"muted.foreground": "#6D7873",
|
||||
"panel.background": "#2E383B",
|
||||
"popover.background": "#262E34",
|
||||
"popover.foreground": "#d3c6aa",
|
||||
"primary.background": "#e69875",
|
||||
"primary.foreground": "#262E34",
|
||||
"scrollbar.background": "#2E383B00",
|
||||
"scrollbar.thumb.background": "#485156",
|
||||
"secondary.background": "#2E383B",
|
||||
"secondary.foreground": "#849087",
|
||||
"secondary.active.background": "#303b3e",
|
||||
"secondary.hover.background": "#3E474B99",
|
||||
"tab.active.background": "#262E34",
|
||||
"tab.active.foreground": "#d3c6aa",
|
||||
"tab.background": "#262E3400",
|
||||
"tab.foreground": "#849087",
|
||||
"tab_bar.background": "#2E383B",
|
||||
"title_bar.background": "#1f262b",
|
||||
"title_bar.border": "#40484c",
|
||||
"base.red": "#e67e80",
|
||||
"base.green": "#a7c080",
|
||||
"base.yellow": "#dbbc7f",
|
||||
"base.blue": "#7fbbb3",
|
||||
"base.magenta": "#844d64",
|
||||
"base.magenta.light": "#844d6499",
|
||||
"base.cyan": "#83c092"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#d3c6aa",
|
||||
"editor.background": "#262E34",
|
||||
"editor.active_line.background": "#3c4448",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#959a9d66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Fahrenheit",
|
||||
"author": "iTerm2-Color-Schemes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Fahrenheit",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#171717",
|
||||
"accent.foreground": "#FFFFCE",
|
||||
"background": "#000000",
|
||||
"border": "#252525",
|
||||
"danger.background": "#593002",
|
||||
"foreground": "#FFFFCE",
|
||||
"input.border": "#252525",
|
||||
"list.active.background": "#72020222",
|
||||
"list.active.border": "#720202",
|
||||
"list.even.background": "#090909",
|
||||
"list.hover.background": "#111111",
|
||||
"muted.background": "#20202099",
|
||||
"muted.foreground": "#828282",
|
||||
"panel.background": "#1e1e1e",
|
||||
"popover.background": "#090909",
|
||||
"popover.foreground": "#FFFFCE",
|
||||
"primary.background": "#720202",
|
||||
"primary.foreground": "#FFFFCE",
|
||||
"scrollbar.background": "#1e1e1e00",
|
||||
"scrollbar.thumb.background": "#3e3e3e",
|
||||
"secondary.background": "#202020",
|
||||
"secondary.active.background": "#202020DD",
|
||||
"secondary.foreground": "#979797",
|
||||
"secondary.hover.background": "#20202099",
|
||||
"tab.foreground": "#808080",
|
||||
"title_bar.background": "#0e0e0e",
|
||||
"table.background": "#1e1e1e00",
|
||||
"table.hover.background": "#1E1E1E",
|
||||
"ring": "#570101",
|
||||
"base.red": "#723202",
|
||||
"base.red.light": "#c97636",
|
||||
"base.green": "#027225",
|
||||
"base.green.light": "#39c936",
|
||||
"base.yellow": "#726302",
|
||||
"base.yellow.light": "#c9b536",
|
||||
"base.blue": "#022d72",
|
||||
"base.blue.light": "#0551cb",
|
||||
"base.magenta": "#3a286c",
|
||||
"base.magenta.light": "#58197a",
|
||||
"base.cyan": "#027272",
|
||||
"base.cyan.light": "#36c9c9"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#FFFFCE",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#1e1e1e",
|
||||
"editor.line_number": "#828282",
|
||||
"editor.invisible": "#82828266",
|
||||
"warning.border": "#720202",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#fecf75"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#828282"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#828282"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#cb4a05"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#dcdcdc"
|
||||
},
|
||||
"function": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#cb4a05"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#cda074",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#9e744d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#cb4a05"
|
||||
},
|
||||
"string": {
|
||||
"color": "#cc734d"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#cc734d"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#cc734d"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#cb4a05"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"title": {
|
||||
"color": "#cda074",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#cda074"
|
||||
},
|
||||
"property": {
|
||||
"color": "#dcdcdc"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#cb4a05"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Flexoki",
|
||||
"author": "kepano",
|
||||
"url": "https://github.com/kepano/flexoki",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Flexoki Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#E8E4CE",
|
||||
"accent.foreground": "#100F0F",
|
||||
"background": "#FFFCF0",
|
||||
"foreground": "#100F0F",
|
||||
"border": "#E6E4D9",
|
||||
"ring": "#D0A215",
|
||||
"selection.background": "#D0A21577",
|
||||
"input.border": "#E6E4D9",
|
||||
"list.active.background": "#D0A21520",
|
||||
"list.active.border": "#D0A215",
|
||||
"list.background": "#FFFCF0",
|
||||
"list.even.background": "#F2F0E5",
|
||||
"muted.background": "#F2F0E5",
|
||||
"muted.foreground": "#6F6E69",
|
||||
"primary.background": "#3AA99F",
|
||||
"primary.foreground": "#FAFAFA",
|
||||
"info.background": "#4385BE",
|
||||
"scrollbar.background": "#FAFAFA00",
|
||||
"scrollbar.thumb.background": "#E6E4D9",
|
||||
"scrollbar.thumb.hover.background": "#DAD8CE",
|
||||
"secondary.active.background": "#DAD8CE",
|
||||
"secondary.background": "#F2F0E5",
|
||||
"secondary.foreground": "#100F0F",
|
||||
"secondary.hover.background": "#E6E4D9",
|
||||
"tab.active.background": "#FFFCF0",
|
||||
"tab.active.foreground": "#100F0F",
|
||||
"tab.background": "#ECECED00",
|
||||
"tab.foreground": "#8d8986",
|
||||
"tab_bar.background": "#F2F0E5",
|
||||
"title_bar.background": "#F2F0E5",
|
||||
"title_bar.border": "#DAD8CE",
|
||||
"base.yellow": "#D0A215",
|
||||
"base.red": "#D14D41",
|
||||
"base.blue": "#4385BE",
|
||||
"base.green": "#879A39",
|
||||
"base.magenta": "#CE5D97",
|
||||
"base.cyan": "#3AA99F"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#100F0F",
|
||||
"editor.background": "#FFFCF0",
|
||||
"editor.active_line.background": "#F2F0E5",
|
||||
"editor.line_number": "#B7B5AC",
|
||||
"editor.active_line_number": "#24837B",
|
||||
"editor.invisible": "#B7B5AC66",
|
||||
"conflict": "#AD8301",
|
||||
"conflict.background": "#FAEEC6",
|
||||
"conflict.border": "#AD8301",
|
||||
"created": "#66800B",
|
||||
"created.background": "#EDEECF",
|
||||
"created.border": "#66800B",
|
||||
"deleted": "#AF3029",
|
||||
"deleted.background": "#FFE1D5",
|
||||
"deleted.border": "#AF3029",
|
||||
"error": "#AF3029",
|
||||
"error.background": "#FFE1D5",
|
||||
"error.border": "#AF3029",
|
||||
"hidden": "#B7B5AC",
|
||||
"hidden.background": "#F2F0E5",
|
||||
"hidden.border": "#B7B5AC",
|
||||
"hint": "#B7B5AC",
|
||||
"hint.background": "#F2F0E5",
|
||||
"hint.border": "#B7B5AC",
|
||||
"ignored": "#B7B5AC",
|
||||
"ignored.background": "#F2F0E5",
|
||||
"ignored.border": "#B7B5AC",
|
||||
"info": "#24837B",
|
||||
"info.background": "#DDF1E4",
|
||||
"info.border": "#24837B",
|
||||
"modified": "#AD8301",
|
||||
"modified.background": "#FAEEC6",
|
||||
"modified.border": "#AD8301",
|
||||
"predictive": "#B7B5AC",
|
||||
"renamed": "#24837B",
|
||||
"renamed.background": "#DDF1E4",
|
||||
"renamed.border": "#24837B",
|
||||
"success": "#66800B",
|
||||
"success.background": "#EDEECF",
|
||||
"success.border": "#66800B",
|
||||
"unreachable": "#6F6E69",
|
||||
"unreachable.background": "#F2F0E5",
|
||||
"unreachable.border": "#6F6E69",
|
||||
"warning": "#AD8301",
|
||||
"warning.background": "#FAEEC6",
|
||||
"warning.border": "#AD8301",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#205EA6"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#BC5215"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#B7B5AC"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#B7B5AC"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#F07171"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#205EA6"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#5C6773"
|
||||
},
|
||||
"function": {
|
||||
"color": "#BC5215"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#66800B"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"number": {
|
||||
"color": "#5E409D"
|
||||
},
|
||||
"string": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#205EA6"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"title": {
|
||||
"color": "#24837B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#AD8301"
|
||||
},
|
||||
"property": {
|
||||
"color": "#BC5215"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#205EA6"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Flexoki Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#242221",
|
||||
"accent.foreground": "#CECDC3",
|
||||
"background": "#100F0F",
|
||||
"border": "#282726",
|
||||
"ring": "#AD8301",
|
||||
"selection.background": "#D0A21577",
|
||||
"foreground": "#CECDC3",
|
||||
"input.border": "#282726",
|
||||
"list.active.background": "#D0A21515",
|
||||
"list.active.border": "#AD8301",
|
||||
"list.even.background": "#1C1B1A",
|
||||
"muted.background": "#1C1B1A",
|
||||
"muted.foreground": "#878580",
|
||||
"panel.background": "#1C1B1A",
|
||||
"popover.background": "#100F0F",
|
||||
"popover.foreground": "#CECDC3",
|
||||
"primary.background": "#24837B",
|
||||
"primary.foreground": "#FFFCF0",
|
||||
"info.background": "#205EA6",
|
||||
"scrollbar.background": "#100F0F00",
|
||||
"scrollbar.thumb.background": "#282726",
|
||||
"scrollbar.thumb.hover.background": "#343331",
|
||||
"secondary.background": "#232221",
|
||||
"secondary.active.background": "#262423",
|
||||
"secondary.foreground": "#CECDC3",
|
||||
"secondary.hover.background": "#232221",
|
||||
"tab.active.background": "#100F0F",
|
||||
"tab.active.foreground": "#CECDC3",
|
||||
"tab.background": "#1C1B1A",
|
||||
"tab.foreground": "#878580",
|
||||
"tab_bar.background": "#1C1B1A99",
|
||||
"title_bar.background": "#191818",
|
||||
"base.yellow": "#AD8301",
|
||||
"base.yellow.light": "#D0A215",
|
||||
"base.red": "#AF3029",
|
||||
"base.red.light": "#D14D41",
|
||||
"base.blue": "#205EA6",
|
||||
"base.blue.light": "#4385BE",
|
||||
"base.green": "#66800B",
|
||||
"base.green.light": "#879A39",
|
||||
"base.magenta": "#A02F6F",
|
||||
"base.magenta.light": "#CE5D97",
|
||||
"base.cyan": "#24837B",
|
||||
"base.cyan.light": "#3AA99F"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#CECDC3",
|
||||
"editor.background": "#100F0F",
|
||||
"editor.active_line.background": "#1C1B1A",
|
||||
"editor.line_number": "#575653",
|
||||
"editor.active_line_number": "#3AA99F",
|
||||
"editor.invisible": "#B7B5AC66",
|
||||
"conflict": "#D0A215",
|
||||
"created": "#879A39",
|
||||
"hidden": "#575653",
|
||||
"hint": "#575653",
|
||||
"modified": "#D0A215",
|
||||
"predictive": "#878580",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#4385BE"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#DA702C"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#575653"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#575653"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#CE5D97"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#4385BE"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#878580"
|
||||
},
|
||||
"function": {
|
||||
"color": "#DA702C"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#879A39"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"number": {
|
||||
"color": "#8B7EC8"
|
||||
},
|
||||
"string": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#4385BE"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"title": {
|
||||
"color": "#D0A215",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#D0A215"
|
||||
},
|
||||
"property": {
|
||||
"color": "#DA702C"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#4385BE"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Gruvbox",
|
||||
"author": "Pavel Pertsev",
|
||||
"url": "https://github.com/morhetz/gruvbox",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Gruvbox Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#e6d19e",
|
||||
"accent.foreground": "#3c3836",
|
||||
"background": "#fbf1c7",
|
||||
"border": "#d5c4a1",
|
||||
"danger.background": "#fb4934",
|
||||
"danger.active.background": "#cc241d",
|
||||
"danger.foreground": "#fbf1c7",
|
||||
"foreground": "#3c3836",
|
||||
"input.border": "#d5c4a1",
|
||||
"link.active.foreground": "#458588",
|
||||
"link.foreground": "#458588",
|
||||
"link.hover.foreground": "#458588",
|
||||
"list.active.background": "#d7992122",
|
||||
"list.hover.background": "#ebdbb2",
|
||||
"list.active.border": "#d79921",
|
||||
"list.even.background": "#f9ecba",
|
||||
"muted.background": "#d5c4a166",
|
||||
"muted.foreground": "#928374",
|
||||
"panel.background": "#ebdbb2",
|
||||
"popover.background": "#fbf1c7",
|
||||
"popover.foreground": "#3c3836",
|
||||
"primary.active.background": "#b7841f",
|
||||
"primary.background": "#d79921",
|
||||
"primary.foreground": "#fbf1c7",
|
||||
"primary.hover.background": "#d79921ee",
|
||||
"ring": "#d79921",
|
||||
"scrollbar.background": "#fbf1c700",
|
||||
"scrollbar.thumb.background": "#d5c4a1",
|
||||
"secondary.background": "#EBDBB2",
|
||||
"secondary.active.background": "#d5c4a1",
|
||||
"secondary.foreground": "#3c3836",
|
||||
"secondary.hover.background": "#e8d5a6",
|
||||
"tab.active.background": "#fbf1c7",
|
||||
"tab.active.foreground": "#3c3836",
|
||||
"tab.background": "#ebdbb200",
|
||||
"tab.foreground": "#928374",
|
||||
"tab_bar.background": "#ebdbb2",
|
||||
"title_bar.background": "#ebdbb2",
|
||||
"title_bar.border": "#d5c4a1",
|
||||
"base.magenta.light": "#D3869B66",
|
||||
"base.red": "#fb4934",
|
||||
"base.green": "#67a64f",
|
||||
"base.yellow": "#b57614",
|
||||
"base.blue": "#076678",
|
||||
"base.magenta": "#8f3f71",
|
||||
"base.cyan": "#427b58"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#3c3836",
|
||||
"editor.background": "#fbf1c7",
|
||||
"editor.active_line.background": "#ebdbb2",
|
||||
"editor.line_number": "#928374",
|
||||
"editor.active_line_number": "#3c3836",
|
||||
"editor.invisible": "#92837466",
|
||||
"conflict": "#cc241d",
|
||||
"created": "#79740e",
|
||||
"deleted": "#9d0006",
|
||||
"error": "#cc241d",
|
||||
"hidden": "#928374",
|
||||
"hint": "#458588",
|
||||
"ignored": "#928374",
|
||||
"info": "#458588",
|
||||
"modified": "#b57614",
|
||||
"predictive": "#928374",
|
||||
"renamed": "#b57614",
|
||||
"success": "#79740e",
|
||||
"unreachable": "#928374",
|
||||
"warning": "#d79921",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#928374",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#928374",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#3c3836"
|
||||
},
|
||||
"function": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#cc241d"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#458588",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#076678",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#cc241d"
|
||||
},
|
||||
"string": {
|
||||
"color": "#79740e"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#79740e"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#79740e"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"title": {
|
||||
"color": "#b57614",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"property": {
|
||||
"color": "#3c3836"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#cc241d"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Gruvbox Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#303030",
|
||||
"accent.foreground": "#ebdbb2",
|
||||
"background": "#1d2021",
|
||||
"border": "#3e3936",
|
||||
"danger.active.background": "#fb4934",
|
||||
"foreground": "#ebdbb2",
|
||||
"input.border": "#504945",
|
||||
"link.active.foreground": "#83a598",
|
||||
"link.foreground": "#83a598",
|
||||
"link.hover.foreground": "#83a598",
|
||||
"list.active.background": "#d7992111",
|
||||
"list.active.border": "#b17f1b",
|
||||
"list.even.background": "#282828",
|
||||
"muted.background": "#3c383655",
|
||||
"muted.foreground": "#928374",
|
||||
"panel.background": "#282828",
|
||||
"popover.background": "#1d2021",
|
||||
"popover.foreground": "#ebdbb2",
|
||||
"primary.background": "#d79921",
|
||||
"primary.foreground": "#282828",
|
||||
"primary.hover.background": "#fabd2f",
|
||||
"ring": "#b17f1b",
|
||||
"scrollbar.background": "#1d202100",
|
||||
"scrollbar.thumb.background": "#504945",
|
||||
"secondary.active.background": "#333332",
|
||||
"secondary.background": "#282828",
|
||||
"secondary.foreground": "#ebdbb2",
|
||||
"secondary.hover.background": "#33333299",
|
||||
"tab.active.background": "#1d2021",
|
||||
"tab.active.foreground": "#ebdbb2",
|
||||
"tab.background": "#28282800",
|
||||
"tab.foreground": "#928374",
|
||||
"tab_bar.background": "#28282899",
|
||||
"title_bar.background": "#252525",
|
||||
"title_bar.border": "#3e3936",
|
||||
"base.magenta.light": "#D3869B66",
|
||||
"base.red": "#f06555",
|
||||
"base.green": "#98971a",
|
||||
"base.yellow": "#d79921",
|
||||
"base.blue": "#458588",
|
||||
"base.magenta": "#b16286",
|
||||
"base.cyan": "#689d6a"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#1d2021",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#92837466",
|
||||
"conflict": "#f06555",
|
||||
"created": "#3f72e2",
|
||||
"deleted": "#9d0006",
|
||||
"error": "#cc241d",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"ignored": "#928374",
|
||||
"info": "#458588",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"renamed": "#b57614",
|
||||
"success": "#79740e",
|
||||
"unreachable": "#928374",
|
||||
"warning": "#d79921",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Harper",
|
||||
"author": "iTerm2-Color-Schemes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Harper",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#27222c",
|
||||
"accent.foreground": "#a8a49d",
|
||||
"background": "#010101",
|
||||
"border": "#333333",
|
||||
"foreground": "#a8a49d",
|
||||
"input.border": "#333333",
|
||||
"list.active.background": "#B196C622",
|
||||
"list.active.border": "#B196C699",
|
||||
"list.even.background": "#18151B77",
|
||||
"muted.background": "#171717",
|
||||
"muted.foreground": "#726E69",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#010101",
|
||||
"popover.foreground": "#a8a49d",
|
||||
"primary.background": "#B196C6",
|
||||
"primary.foreground": "#000000",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#726E69",
|
||||
"secondary.background": "#1b1b1b",
|
||||
"secondary.active.background": "#2c2632",
|
||||
"secondary.foreground": "#A8A49D",
|
||||
"secondary.hover.background": "#23232399",
|
||||
"tab.active.background": "#010101",
|
||||
"tab.active.foreground": "#a8a49d",
|
||||
"tab.foreground": "#787878",
|
||||
"title_bar.background": "#101010",
|
||||
"base.red": "#ff5874",
|
||||
"base.red.light": "#ff587499",
|
||||
"base.green": "#489e48",
|
||||
"base.green.light": "#489e4899",
|
||||
"base.blue": "#7fb5e1",
|
||||
"base.blue.light": "#7fb5e199",
|
||||
"base.cyan": "#bff5e5",
|
||||
"base.cyan.light": "#bff5e599",
|
||||
"base.magenta": "#b296c6",
|
||||
"base.magenta.light": "#b296c699",
|
||||
"base.yellow": "#f8b63f",
|
||||
"base.yellow.light": "#f8b63f99"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#a8a49d",
|
||||
"editor.background": "#010101",
|
||||
"editor.active_line.background": "#18151B",
|
||||
"editor.line_number": "#726E69",
|
||||
"editor.active_line_number": "#a8a49d",
|
||||
"editor.invisible": "#726E6966",
|
||||
"conflict": "#ff5874",
|
||||
"created": "#489e48",
|
||||
"deleted": "#ff5874",
|
||||
"error": "#ff5874",
|
||||
"hidden": "#726E69",
|
||||
"hint": "#7fb5e1",
|
||||
"ignored": "#726E69",
|
||||
"info": "#7fb5e1",
|
||||
"modified": "#b296c6",
|
||||
"predictive": "#726E69",
|
||||
"renamed": "#b296c6",
|
||||
"success": "#489e48",
|
||||
"unreachable": "#726E69",
|
||||
"warning": "#f8b63f",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#b296c6"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#726E69",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#726E69",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b296c6"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#a8a49d"
|
||||
},
|
||||
"function": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#ff5874"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#7fb5e1",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#bff5e5",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#ff5874"
|
||||
},
|
||||
"string": {
|
||||
"color": "#489e48"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#489e48"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#489e48"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b296c6"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"title": {
|
||||
"color": "#b296c6",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#b296c6"
|
||||
},
|
||||
"property": {
|
||||
"color": "#a8a49d"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#ff5874"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Hybrid",
|
||||
"author": "Andrew Wong",
|
||||
"url": "https://github.com/w0ng/vim-hybrid",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Hybrid Light",
|
||||
"mode": "light",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#c8ced1",
|
||||
"accent.foreground": "#1c1c1c",
|
||||
"background": "#E4E4E4",
|
||||
"border": "#CACACA",
|
||||
"danger.background": "#ff5f5f",
|
||||
"danger.foreground": "#ffffff",
|
||||
"foreground": "#1c1c1c",
|
||||
"input.border": "#CACACA",
|
||||
"link.active.foreground": "#005f87",
|
||||
"link.foreground": "#005f87",
|
||||
"link.hover.foreground": "#005f87",
|
||||
"list.active.background": "#79792C20",
|
||||
"list.active.border": "#79792C",
|
||||
"list.hover.background": "#D5D5D5",
|
||||
"list.even.background": "#DFDFDF",
|
||||
"muted.background": "#d7d7d7",
|
||||
"muted.foreground": "#5f5f5f",
|
||||
"primary.background": "#005f87",
|
||||
"primary.foreground": "#ffffff",
|
||||
"scrollbar.background": "#E0E0E000",
|
||||
"scrollbar.thumb.background": "#8f8f8f",
|
||||
"secondary.background": "#D0D0D0",
|
||||
"secondary.active.background": "#c4c4c4",
|
||||
"secondary.foreground": "#1c1c1c",
|
||||
"secondary.hover.background": "#c4c4c499",
|
||||
"tab.active.background": "#E4E4E4",
|
||||
"tab.active.foreground": "#1c1c1c",
|
||||
"tab.background": "#e8e8e800",
|
||||
"tab.foreground": "#5f5f5f",
|
||||
"tab_bar.background": "#e8e8e8",
|
||||
"title_bar.background": "#D0D0D0",
|
||||
"title_bar.border": "#B3B3B3",
|
||||
"status_bar.background": "#DADADA",
|
||||
"base.red": "#5F0000",
|
||||
"base.green": "#005F00",
|
||||
"base.yellow": "#948000",
|
||||
"base.blue": "#00195f",
|
||||
"base.magenta": "#5f1c51",
|
||||
"base.magenta.light": "#5f1c5111",
|
||||
"base.cyan": "#005a5f"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#1c1c1c",
|
||||
"editor.background": "#E4E4E4",
|
||||
"editor.active_line.background": "#D3D3D3",
|
||||
"editor.line_number": "#5F5F5F",
|
||||
"editor.active_line_number": "#1C1C1C",
|
||||
"editor.invisible": "#5F5F5F66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3F72E2",
|
||||
"deleted": "#FF5F5F",
|
||||
"error": "#FF5F5F",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#5F87AF",
|
||||
"ignored": "#B3B3B3",
|
||||
"info": "#005F87",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"renamed": "#5F87AF",
|
||||
"success": "#005F00",
|
||||
"unreachable": "#9E9E9E",
|
||||
"warning": "#948000",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#948000"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#5F5F5F",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#5F5F5F",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#79792C"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#1C1C1C"
|
||||
},
|
||||
"function": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#5F1C51"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#005F87",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#005F87",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"string": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#79792C"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"title": {
|
||||
"color": "#005F87",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#5F1C51"
|
||||
},
|
||||
"property": {
|
||||
"color": "#1C1C1C"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#5F1C51"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Hybrid Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#31393a",
|
||||
"accent.foreground": "#e8e8e8",
|
||||
"background": "#1D1F21",
|
||||
"border": "#34373c",
|
||||
"foreground": "#e8e8e8",
|
||||
"input.border": "#34373c",
|
||||
"link.active.foreground": "#87d7ff",
|
||||
"link.foreground": "#81A2BE",
|
||||
"link.hover.foreground": "#006f9f",
|
||||
"list.active.background": "#15678a10",
|
||||
"list.active.border": "#15678a",
|
||||
"list.even.background": "#282A2E",
|
||||
"muted.background": "#282A2E99",
|
||||
"muted.foreground": "#878787",
|
||||
"panel.background": "#1D1F21",
|
||||
"popover.background": "#1D1F21",
|
||||
"popover.foreground": "#e8e8e8",
|
||||
"primary.background": "#15678a",
|
||||
"primary.foreground": "#e8e8e8",
|
||||
"scrollbar.background": "#1D1F2100",
|
||||
"scrollbar.thumb.background": "#5f5f5f",
|
||||
"secondary.background": "#303336",
|
||||
"secondary.active.background": "#33363b",
|
||||
"secondary.foreground": "#e8e8e8",
|
||||
"secondary.hover.background": "#373b3e",
|
||||
"tab.active.background": "#1D1F21",
|
||||
"tab.active.foreground": "#d4d4d4",
|
||||
"tab.background": "#24262700",
|
||||
"tab.foreground": "#999999",
|
||||
"tab_bar.background": "#242627",
|
||||
"title_bar.background": "#242629",
|
||||
"title_bar.border": "#34373c",
|
||||
"base.red": "#8a1515",
|
||||
"base.red.light": "#ff5f5f",
|
||||
"base.green": "#7c8a15",
|
||||
"base.green.light": "#b3bf5a",
|
||||
"base.yellow": "#8a7c15",
|
||||
"base.yellow.light": "#e4b55e",
|
||||
"base.blue": "#15678a",
|
||||
"base.blue.light": "#6e90b0",
|
||||
"base.magenta": "#8a1567",
|
||||
"base.magenta.light": "#B294BB",
|
||||
"base.cyan": "#15678a",
|
||||
"base.cyan.light": "#7fbfb4"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#5F5F5F66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#802207",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0C194D",
|
||||
"info.border": "#082190",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Jellybeans",
|
||||
"author": "nanotech",
|
||||
"url": "https://github.com/nanotech/jellybeans.vim",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Jellybeans",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#292929",
|
||||
"accent.foreground": "#e8e8e8",
|
||||
"background": "#151515",
|
||||
"border": "#2e2e2e",
|
||||
"danger.foreground": "#151515",
|
||||
"foreground": "#E8E8D3",
|
||||
"input.border": "#4E4847",
|
||||
"link.active.foreground": "#97bedc",
|
||||
"link.foreground": "#97bedc",
|
||||
"link.hover.foreground": "#97bedc",
|
||||
"list.active.background": "#97bedc15",
|
||||
"list.active.border": "#97bedc",
|
||||
"list.even.background": "#1C1C1C",
|
||||
"muted.background": "#242424",
|
||||
"muted.foreground": "#767676",
|
||||
"panel.background": "#151515",
|
||||
"popover.background": "#151515",
|
||||
"popover.foreground": "#e8e8e8",
|
||||
"primary.background": "#97bedc",
|
||||
"primary.foreground": "#151515",
|
||||
"scrollbar.background": "#26262600",
|
||||
"scrollbar.thumb.background": "#6e6e6e",
|
||||
"secondary.background": "#2F2F2F",
|
||||
"secondary.active.background": "#2f2f2f",
|
||||
"secondary.foreground": "#e8e8e8",
|
||||
"secondary.hover.background": "#252525",
|
||||
"tab.active.background": "#151515",
|
||||
"tab.active.foreground": "#e8e8e8",
|
||||
"tab.background": "#10101000",
|
||||
"tab.foreground": "#969696",
|
||||
"tab_bar.background": "#101010",
|
||||
"title_bar.background": "#101010",
|
||||
"title_bar.border": "#2e2e2e",
|
||||
"base.red": "#e27373",
|
||||
"base.red.light": "#ffa1a1",
|
||||
"base.green": "#94b979",
|
||||
"base.green.light": "#bddeab",
|
||||
"base.yellow": "#ffba7b",
|
||||
"base.yellow.light": "#ffdca0",
|
||||
"base.blue": "#97bedc",
|
||||
"base.blue.light": "#b1d8f6",
|
||||
"base.magenta": "#B294BB",
|
||||
"base.magenta.light": "#fbdaff",
|
||||
"base.cyan": "#00988e",
|
||||
"base.cyan.light": "#1ab2a8"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#9E9E9E66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Kibble",
|
||||
"author": "iTerm2-Color-Schemes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Kibble",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#252525",
|
||||
"accent.foreground": "#f7f7f7",
|
||||
"background": "#0e100a",
|
||||
"border": "#292a24",
|
||||
"danger.background": "#e07b5c",
|
||||
"foreground": "#f7f7f7",
|
||||
"input.border": "#333333",
|
||||
"list.active.background": "#2BCF1315",
|
||||
"list.active.border": "#2BCF13",
|
||||
"list.hover.background": "#2c292b54",
|
||||
"list.even.background": "#24222355",
|
||||
"muted.background": "#292a2455",
|
||||
"muted.foreground": "#777777",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#0e100a",
|
||||
"popover.foreground": "#f7f7f7",
|
||||
"primary.active.background": "#6ce05c99",
|
||||
"primary.background": "#6ce05c",
|
||||
"primary.foreground": "#101010",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#726E69",
|
||||
"secondary.background": "#22231f",
|
||||
"secondary.active.background": "#292825",
|
||||
"secondary.foreground": "#f7f7f7",
|
||||
"secondary.hover.background": "#22231f",
|
||||
"success.background": "#5c6ee0",
|
||||
"warning.background": "#e0c85c",
|
||||
"info.background": "#5cd1e0",
|
||||
"selection.background": "#9ba787",
|
||||
"tab.active.background": "#0e100a",
|
||||
"tab.active.foreground": "#f7f7f7",
|
||||
"tab.foreground": "#969696",
|
||||
"title_bar.background": "#161712",
|
||||
"base.red": "#C70231",
|
||||
"base.red.light": "#e07b5c",
|
||||
"base.green": "#2BCF13",
|
||||
"base.green.light": "#6ce05c",
|
||||
"base.blue": "#3449d1",
|
||||
"base.blue.light": "#5c6ee0",
|
||||
"base.cyan": "#0798ab",
|
||||
"base.cyan.light": "#5cd1e0",
|
||||
"base.magenta": "#8400ff",
|
||||
"base.magenta.light": "#C495F0",
|
||||
"base.yellow": "#c7a302",
|
||||
"base.yellow.light": "#e0c85c"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#f7f7f7",
|
||||
"editor.background": "#0e100a",
|
||||
"editor.active_line.background": "#242223",
|
||||
"editor.line_number": "#726E69",
|
||||
"editor.active_line_number": "#f7f7f7",
|
||||
"editor.invisible": "#726E6966",
|
||||
"conflict": "#c70031",
|
||||
"created": "#2BCF13",
|
||||
"deleted": "#c70031",
|
||||
"error": "#c70031",
|
||||
"hidden": "#726E69",
|
||||
"hint": "#0798ab",
|
||||
"ignored": "#726E69",
|
||||
"info": "#0798ab",
|
||||
"modified": "#3449d1",
|
||||
"predictive": "#726E69",
|
||||
"renamed": "#3449d1",
|
||||
"success": "#2BCF13",
|
||||
"unreachable": "#726E69",
|
||||
"warning": "#c7a302",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#3449d1"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#726E69",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#726E69",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#3449d1"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#f7f7f7"
|
||||
},
|
||||
"function": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#c70031"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#0798ab",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#68f2e0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#c70031"
|
||||
},
|
||||
"string": {
|
||||
"color": "#2BCF13"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#2BCF13"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#2BCF13"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#3449d1"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"title": {
|
||||
"color": "#3449d1",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#3449d1"
|
||||
},
|
||||
"property": {
|
||||
"color": "#f7f7f7"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#c70031"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "macOS Classic",
|
||||
"author": "huacnlee",
|
||||
"url": "https://github.com/huacnlee/zed-theme-macos-classic",
|
||||
"themes": [
|
||||
{
|
||||
"name": "macOS Classic Light",
|
||||
"mode": "light",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#E0E0E0",
|
||||
"accent.foreground": "#000000",
|
||||
"background": "#F9F9F9",
|
||||
"foreground": "#000000",
|
||||
"border": "#D2D2D2",
|
||||
"ring": "#0060de",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"list.active.background": "#0060de15",
|
||||
"list.active.border": "#0060de",
|
||||
"list.even.background": "#EFEFEF",
|
||||
"list.hover.background": "#E7E7E7",
|
||||
"muted.background": "#EAEAEA",
|
||||
"muted.foreground": "#707070",
|
||||
"popover.background": "#F5F5F5",
|
||||
"popover.foreground": "#000000",
|
||||
"primary.background": "#0060de",
|
||||
"primary.foreground": "#FFFFFF",
|
||||
"scrollbar.background": "#FFFFFF00",
|
||||
"scrollbar.thumb.background": "#C8C8C8",
|
||||
"secondary.active.background": "#D9D9D9",
|
||||
"secondary.background": "#E0E0E0",
|
||||
"secondary.foreground": "#000000",
|
||||
"secondary.hover.background": "#E5E5E5",
|
||||
"tab.active.foreground": "#000000",
|
||||
"tab.background": "#E9E9E9",
|
||||
"tab.foreground": "#606060",
|
||||
"tab_bar.background": "#E9E9E9",
|
||||
"title_bar.background": "#F7F7F7",
|
||||
"title_bar.border": "#DADADA",
|
||||
"status_bar.background": "#E9E9E9",
|
||||
"base.yellow": "#B59A00",
|
||||
"base.red": "#d21f07",
|
||||
"base.blue": "#0060de",
|
||||
"base.green": "#319a00",
|
||||
"base.magenta": "#9A0068",
|
||||
"base.cyan": "#007E8A"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#000000",
|
||||
"editor.background": "#ffffff",
|
||||
"editor.active_line.background": "#F5F5F5",
|
||||
"editor.line_number": "#929292",
|
||||
"editor.active_line_number": "#000000",
|
||||
"editor.invisible": "#007fff66",
|
||||
"conflict": "#d21f07",
|
||||
"created": "#0060de",
|
||||
"hidden": "#6D6D6D",
|
||||
"hint": "#9A0068",
|
||||
"modified": "#319a00",
|
||||
"predictive": "#A4ABB6",
|
||||
"warning": "#B59A00",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#957931"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"function": {
|
||||
"color": "#0000A2"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#0000A2",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6A7293",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#6F42C1"
|
||||
},
|
||||
"title": {
|
||||
"color": "#0433FF"
|
||||
},
|
||||
"type": {
|
||||
"color": "#6f42c1"
|
||||
},
|
||||
"property": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#C5060B"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "macOS Classic Dark",
|
||||
"mode": "dark",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#282629",
|
||||
"accent.foreground": "#CACCCA",
|
||||
"background": "#131313",
|
||||
"border": "#303030",
|
||||
"ring": "#077CFD",
|
||||
"foreground": "#DEDEDE",
|
||||
"danger.background": "#E44A4F",
|
||||
"list.even.background": "#232323",
|
||||
"list.active.background": "#0059D115",
|
||||
"list.active.border": "#077CFD",
|
||||
"muted.background": "#202020",
|
||||
"muted.foreground": "#9D9D9D",
|
||||
"popover.background": "#101010",
|
||||
"popover.foreground": "#CACCCA",
|
||||
"primary.background": "#077CFD",
|
||||
"primary.foreground": "#F2F9FF",
|
||||
"switch.background": "#393939",
|
||||
"switch.thumb.background": "#DAECFF",
|
||||
"scrollbar.background": "#13131300",
|
||||
"scrollbar.thumb.background": "#9F9F9F",
|
||||
"secondary.active.background": "#353535",
|
||||
"secondary.background": "#353535",
|
||||
"secondary.foreground": "#DEDEDE",
|
||||
"secondary.hover.background": "#35353599",
|
||||
"link": "#419CFF",
|
||||
"tab_bar.background": "#1C1C1E",
|
||||
"tab.active.background": "#131313",
|
||||
"tab.foreground": "#8F8F8F",
|
||||
"title_bar.background": "#171717",
|
||||
"selection.background": "#3F638B",
|
||||
"base.blue": "#419CFF",
|
||||
"base.cyan": "#07FDD3",
|
||||
"base.green": "#30D158",
|
||||
"base.magenta": "#A550A7",
|
||||
"base.red": "#FF5257",
|
||||
"base.yellow": "#FFC600"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#CACCCA",
|
||||
"editor.background": "#131313",
|
||||
"editor.active_line.background": "#35343666",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#007fff66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#E44A4F",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0059D1",
|
||||
"info.border": "#0059D1",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#7FAEF9"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9D9D9D"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9D9D9D"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#CBA6F7"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#B3C5F3"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#87B1F6"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#419CFF",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#7faef9",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"string": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"text.code.span": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#68DC7C"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#68DC7C"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#419CFF"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"title": {
|
||||
"color": "#CC9E00",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#CBA6F7"
|
||||
},
|
||||
"property": {
|
||||
"color": "#BCC4E0"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#419CFF"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Mellifluous",
|
||||
"author": "Ramojus Lapinskas",
|
||||
"url": "https://github.com/ramojus/mellifluous.nvim",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Mellifluous Light",
|
||||
"mode": "light",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#d4d4d4",
|
||||
"accent.foreground": "#383a42",
|
||||
"background": "#E7E7E7",
|
||||
"border": "#CACACA",
|
||||
"danger.active.background": "#e06c75",
|
||||
"danger.hover.background": "#be5046",
|
||||
"foreground": "#383a42",
|
||||
"link.active.foreground": "#5A6599",
|
||||
"link.foreground": "#5A6599",
|
||||
"link.hover.foreground": "#5A6599",
|
||||
"list.hover.background": "#d5d5d5",
|
||||
"list.active.background": "#5A659912",
|
||||
"list.active.border": "#5A6599",
|
||||
"list.even.background": "#F0F0F0",
|
||||
"muted.background": "#E0E0E0",
|
||||
"muted.foreground": "#828997",
|
||||
"panel.background": "#fafafa",
|
||||
"popover.background": "#E4E4E4",
|
||||
"popover.foreground": "#383a42",
|
||||
"primary.active.background": "#5A6599AA",
|
||||
"primary.background": "#5A6599",
|
||||
"primary.foreground": "#F0F0F0",
|
||||
"primary.hover.background": "#626ca2",
|
||||
"scrollbar.background": "#fafafa00",
|
||||
"scrollbar.thumb.background": "#c0c0c0",
|
||||
"secondary.background": "#d0d0d0",
|
||||
"secondary.active.background": "#cccccc",
|
||||
"secondary.foreground": "#383a42",
|
||||
"secondary.hover.background": "#d7d7d7",
|
||||
"tab.active.background": "#E7E7E7",
|
||||
"tab.active.foreground": "#383a42",
|
||||
"tab.background": "#F0F0F000",
|
||||
"tab.foreground": "#727272",
|
||||
"tab_bar.background": "#E7E7E7",
|
||||
"title_bar.background": "#dfdfdf",
|
||||
"title_bar.border": "#CACACA",
|
||||
"status_bar.background": "#dfdfdf",
|
||||
"base.red": "#C95954",
|
||||
"base.green": "#828040",
|
||||
"base.yellow": "#c98f54",
|
||||
"base.blue": "#a8a1be",
|
||||
"base.magenta": "#b39fb0",
|
||||
"base.magenta.light": "#9C699588",
|
||||
"base.cyan": "#54c981"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#383a42",
|
||||
"editor.background": "#E7E7E7",
|
||||
"editor.active_line.background": "#d5d5d5",
|
||||
"editor.line_number": "#727272",
|
||||
"editor.active_line_number": "#383a42",
|
||||
"editor.invisible": "#A0A0A066",
|
||||
"conflict": "#e06c75",
|
||||
"created": "#828040",
|
||||
"deleted": "#be5046",
|
||||
"error": "#e06c75",
|
||||
"hidden": "#727272",
|
||||
"hint": "#5A6599",
|
||||
"ignored": "#727272",
|
||||
"info": "#5A6599",
|
||||
"modified": "#727272",
|
||||
"predictive": "#727272",
|
||||
"renamed": "#727272",
|
||||
"success": "#828040",
|
||||
"unreachable": "#727272",
|
||||
"warning": "#cbaa89",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#727272"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#A0A0A0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#A0A0A0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#727272"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#383a42"
|
||||
},
|
||||
"function": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e06c75"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#5A6599",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#5A6599",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e06c75"
|
||||
},
|
||||
"string": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#727272"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"title": {
|
||||
"color": "#727272",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#727272"
|
||||
},
|
||||
"property": {
|
||||
"color": "#383a42"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e06c75"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Mellifluous Dark",
|
||||
"mode": "dark",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#2f2f2f",
|
||||
"accent.foreground": "#abb2bf",
|
||||
"background": "#1A1A1A",
|
||||
"border": "#444444",
|
||||
"foreground": "#abb2bf",
|
||||
"input.border": "#444444",
|
||||
"link.active.foreground": "#5A6599",
|
||||
"link.foreground": "#5A6599",
|
||||
"link.hover.foreground": "#5A6599",
|
||||
"list.active.background": "#5A659922",
|
||||
"list.active.border": "#5A6599",
|
||||
"list.even.background": "#29292988",
|
||||
"muted.background": "#44444455",
|
||||
"muted.foreground": "#828997",
|
||||
"panel.background": "#282c34",
|
||||
"popover.background": "#1A1A1A",
|
||||
"popover.foreground": "#abb2bf",
|
||||
"primary.active.background": "#5A6599AA",
|
||||
"primary.background": "#5A6599",
|
||||
"primary.foreground": "#ecedf4",
|
||||
"primary.hover.background": "#626ca2",
|
||||
"scrollbar.background": "#282c3400",
|
||||
"scrollbar.thumb.background": "#444444",
|
||||
"secondary.background": "#292929",
|
||||
"secondary.active.background": "#323232",
|
||||
"secondary.foreground": "#abb2bf",
|
||||
"secondary.hover.background": "#292929",
|
||||
"tab.active.background": "#1A1A1A",
|
||||
"tab.active.foreground": "#abb2bf",
|
||||
"tab.background": "#1A1A1A00",
|
||||
"tab.foreground": "#abb2bf",
|
||||
"title_bar.background": "#171717",
|
||||
"title_bar.border": "#444444",
|
||||
"base.red": "#C95954",
|
||||
"base.green": "#828040",
|
||||
"base.yellow": "#c98d54",
|
||||
"base.blue": "#5481c9",
|
||||
"base.magenta": "#9C6995",
|
||||
"base.magenta.light": "#9C699577",
|
||||
"base.cyan": "#54c9bd"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#abb2bf",
|
||||
"editor.background": "#1A1A1A",
|
||||
"editor.active_line.background": "#29292977",
|
||||
"editor.line_number": "#828997",
|
||||
"editor.active_line_number": "#abb2bf",
|
||||
"editor.invisible": "#A0A0A066",
|
||||
"conflict": "#e06c75",
|
||||
"conflict.background": "#1A1A1A",
|
||||
"conflict.border": "#be5046",
|
||||
"created": "#828040",
|
||||
"created.background": "#292929",
|
||||
"created.border": "#929292",
|
||||
"deleted": "#be5046",
|
||||
"deleted.background": "#1A1A1A",
|
||||
"deleted.border": "#e06c75",
|
||||
"error": "#e06c75",
|
||||
"error.background": "#1A1A1A",
|
||||
"error.border": "#be5046",
|
||||
"hidden": "#828997",
|
||||
"hidden.background": "#292929",
|
||||
"hidden.border": "#444444",
|
||||
"hint": "#5A6599",
|
||||
"hint.background": "#292929",
|
||||
"hint.border": "#626ca2",
|
||||
"ignored": "#828997",
|
||||
"ignored.background": "#292929",
|
||||
"ignored.border": "#444444",
|
||||
"info": "#5A6599",
|
||||
"info.background": "#292929",
|
||||
"info.border": "#626ca2",
|
||||
"modified": "#929292",
|
||||
"modified.background": "#292929",
|
||||
"modified.border": "#444444",
|
||||
"predictive": "#828997",
|
||||
"predictive.background": "#292929",
|
||||
"predictive.border": "#444444",
|
||||
"renamed": "#929292",
|
||||
"renamed.background": "#292929",
|
||||
"renamed.border": "#444444",
|
||||
"success": "#828040",
|
||||
"success.background": "#292929",
|
||||
"success.border": "#929292",
|
||||
"unreachable": "#828997",
|
||||
"unreachable.background": "#292929",
|
||||
"unreachable.border": "#444444",
|
||||
"warning": "#d19a66",
|
||||
"warning.background": "#292929",
|
||||
"warning.border": "#929292",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#929292"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#828997",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#828997",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#929292"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#abb2bf"
|
||||
},
|
||||
"function": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e06c75"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#5A6599",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#5A6599",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e06c75"
|
||||
},
|
||||
"string": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#929292"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"title": {
|
||||
"color": "#929292",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#929292"
|
||||
},
|
||||
"property": {
|
||||
"color": "#abb2bf"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e06c75"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Molokai",
|
||||
"author": "Tomas Restrepo",
|
||||
"url": "https://github.com/tomasr/molokai",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Molokai Light",
|
||||
"mode": "light",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#EBE4E1",
|
||||
"accent.foreground": "#060606",
|
||||
"background": "#FEFAF9",
|
||||
"border": "#E4DEDA",
|
||||
"window_border": "#d6d6d6",
|
||||
"ring": "#a0a0ab",
|
||||
"foreground": "#0a0a0a",
|
||||
"input.border": "#E4DEDA",
|
||||
"list.active.background": "#E1477411",
|
||||
"list.active.border": "#E14774AA",
|
||||
"list.even.background": "#E4DEDA33",
|
||||
"muted.background": "#f1eded",
|
||||
"muted.foreground": "#767676",
|
||||
"panel.background": "#FEFAF9",
|
||||
"popover.background": "#FEFAF9",
|
||||
"popover.foreground": "#0a0a0a",
|
||||
"primary.background": "#E14774",
|
||||
"primary.foreground": "#fafafa",
|
||||
"scrollbar.background": "#FEFAF900",
|
||||
"scrollbar.thumb.background": "#ADADB0",
|
||||
"secondary.background": "#E4DEDA",
|
||||
"secondary.foreground": "#060606",
|
||||
"secondary.hover.background": "#E4DEDA99",
|
||||
"title_bar.background": "#e8e3e0",
|
||||
"title_bar.border": "#d5ccc6",
|
||||
"base.red": "#e17047",
|
||||
"base.green": "#82cc0a",
|
||||
"base.yellow": "#ccac0a",
|
||||
"base.blue": "#e16032",
|
||||
"base.blue.light": "#e16032",
|
||||
"base.magenta": "#7058be",
|
||||
"base.cyan": "#0acc78"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#0a0a0a",
|
||||
"editor.background": "#FEFAF9",
|
||||
"editor.active_line.background": "#E4DEDA",
|
||||
"editor.line_number": "#767676",
|
||||
"editor.active_line_number": "#0a0a0a",
|
||||
"editor.invisible": "#76767666",
|
||||
"conflict": "#e14775",
|
||||
"created": "#269D69",
|
||||
"deleted": "#e14775",
|
||||
"error": "#e14775",
|
||||
"hidden": "#767676",
|
||||
"hint": "#7058be",
|
||||
"ignored": "#767676",
|
||||
"info": "#1c8ca8",
|
||||
"modified": "#cc7a0a",
|
||||
"predictive": "#ADADB0",
|
||||
"renamed": "#E14774",
|
||||
"success": "#269D69",
|
||||
"unreachable": "#767676",
|
||||
"warning": "#cc7a0a",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#cc7a0a"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#767676",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#767676",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#7058be"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#269D69"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#1c8ca8"
|
||||
},
|
||||
"function": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e14775",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#cc7a0a",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#7058be",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#cc7a0a"
|
||||
},
|
||||
"string": {
|
||||
"color": "#269D69"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#7058be"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#269D69"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#7058be"
|
||||
},
|
||||
"title": {
|
||||
"color": "#cc7a0a",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#1c8ca8"
|
||||
},
|
||||
"property": {
|
||||
"color": "#7058be"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e14775"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Molokai Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2b2f30",
|
||||
"accent.foreground": "#f8f8f2",
|
||||
"background": "#1b1d1e",
|
||||
"border": "#393939",
|
||||
"foreground": "#f8f8f2",
|
||||
"input.border": "#3b3b3b",
|
||||
"list.active.background": "#f9267211",
|
||||
"list.active.border": "#f9267288",
|
||||
"list.even.background": "#292c2e",
|
||||
"muted.background": "#232526",
|
||||
"muted.foreground": "#5b5a54",
|
||||
"panel.background": "#1b1d1e",
|
||||
"popover.background": "#1b1d1e",
|
||||
"popover.foreground": "#f8f8f2",
|
||||
"primary.background": "#dc1860",
|
||||
"primary.foreground": "#f8f8f2",
|
||||
"scrollbar.background": "#1b1d1e00",
|
||||
"scrollbar.thumb.background": "#4b4b4b",
|
||||
"secondary.background": "#292c2e",
|
||||
"secondary.active.background": "#303436",
|
||||
"secondary.foreground": "#f5f5f4",
|
||||
"secondary.hover.background": "#30343699",
|
||||
"table.row.border": "#3b3b3b70",
|
||||
"title_bar.background": "#202223",
|
||||
"title_bar.border": "#3b3b3b",
|
||||
"base.red": "#dc1860",
|
||||
"base.green": "#82cc0a",
|
||||
"base.yellow": "#807607",
|
||||
"base.blue": "#075880",
|
||||
"base.magenta": "#800780",
|
||||
"base.cyan": "#07807e"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#76767666",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#802207",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0C194D",
|
||||
"info.border": "#082190",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Solarized",
|
||||
"author": "Ethan Schoonover",
|
||||
"url": "https://ethanschoonover.com/solarized",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Solarized Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#ebe4ce",
|
||||
"accent.foreground": "#073642",
|
||||
"background": "#FDF6E3",
|
||||
"border": "#DCD4BC",
|
||||
"foreground": "#586E75",
|
||||
"input.border": "#DCD4BC",
|
||||
"link.foreground": "#587573",
|
||||
"list.active.background": "#586E75",
|
||||
"list.active.border": "#586E75",
|
||||
"list.even.background": "#EEE8D599",
|
||||
"muted.background": "#eee8d5",
|
||||
"muted.foreground": "#93a1a1",
|
||||
"panel.background": "#fdf6e3",
|
||||
"popover.background": "#fdf6e3",
|
||||
"popover.foreground": "#073642",
|
||||
"primary.background": "#586E75",
|
||||
"primary.foreground": "#fdf6e3",
|
||||
"scrollbar.background": "#EEE8D500",
|
||||
"scrollbar.thumb.background": "#d5cbaf",
|
||||
"secondary.background": "#EEE8D5",
|
||||
"secondary.active.background": "#DCD4BC",
|
||||
"secondary.foreground": "#073642",
|
||||
"secondary.hover.background": "#DCD4BC88",
|
||||
"tab.active.background": "#fdf6e3",
|
||||
"tab.active.foreground": "#073642",
|
||||
"tab.background": "#fdf6e300",
|
||||
"tab.foreground": "#657b83",
|
||||
"tab_bar.background": "#eee8d5",
|
||||
"title_bar.background": "#f4ecd7",
|
||||
"title_bar.border": "#dcd4bc",
|
||||
"chart.grid": "#ebe5d4",
|
||||
"base.red": "#755858",
|
||||
"base.green": "#717558",
|
||||
"base.yellow": "#756e58",
|
||||
"base.blue": "#586975",
|
||||
"base.magenta": "#755866",
|
||||
"base.cyan": "#587573"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#586E75",
|
||||
"editor.background": "#FDF6E3",
|
||||
"editor.active_line.background": "#EEE8D5",
|
||||
"editor.line_number": "#93A1A1",
|
||||
"editor.active_line_number": "#073642",
|
||||
"editor.invisible": "#93A1A166",
|
||||
"conflict": "#DC322F",
|
||||
"created": "#859900",
|
||||
"deleted": "#DC322F",
|
||||
"error": "#DC322F",
|
||||
"hidden": "#93A1A1",
|
||||
"hint": "#2AA198",
|
||||
"ignored": "#93A1A1",
|
||||
"info": "#268BD2",
|
||||
"modified": "#B58900",
|
||||
"predictive": "#586E75",
|
||||
"renamed": "#2AA198",
|
||||
"success": "#859900",
|
||||
"unreachable": "#DC322F",
|
||||
"warning": "#B58900",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#B58900"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#CB4B16"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#93A1A1",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#93A1A1",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#268BD2"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#859900"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#2AA198"
|
||||
},
|
||||
"function": {
|
||||
"color": "#268BD2"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#DC322F"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#268BD2",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#2AA198",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#D33682"
|
||||
},
|
||||
"string": {
|
||||
"color": "#859900"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#2AA198"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#D33682"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#B58900"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#B58900"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#268BD2"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#586E75"
|
||||
},
|
||||
"title": {
|
||||
"color": "#073642",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#2AA198"
|
||||
},
|
||||
"property": {
|
||||
"color": "#586E75"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#DC322F"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Solarized Dark",
|
||||
"author": "Ethan Schoonover",
|
||||
"url": "https://ethanschoonover.com/solarized",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#073945",
|
||||
"accent.foreground": "#fdf6e3",
|
||||
"background": "#002B36",
|
||||
"border": "#103a44",
|
||||
"foreground": "#fdf6e3",
|
||||
"input.border": "#083e4c",
|
||||
"list.active.background": "#0d667d25",
|
||||
"list.active.border": "#0d667d",
|
||||
"list.even.background": "#15333A99",
|
||||
"muted.background": "#073642",
|
||||
"muted.foreground": "#839496",
|
||||
"panel.background": "#002b36",
|
||||
"popover.background": "#002b36",
|
||||
"popover.foreground": "#fdf6e3",
|
||||
"primary.background": "#0d667d",
|
||||
"primary.foreground": "#fdf6e3",
|
||||
"scrollbar.background": "#002b3600",
|
||||
"scrollbar.thumb.background": "#586e75",
|
||||
"secondary.background": "#073642",
|
||||
"secondary.active.background": "#083e4b",
|
||||
"secondary.foreground": "#fdf6e3",
|
||||
"secondary.hover.background": "#073945",
|
||||
"tab.active.background": "#002b36",
|
||||
"tab.active.foreground": "#fdf6e3",
|
||||
"tab.foreground": "#93a1a1",
|
||||
"tab_bar.background": "#07364266",
|
||||
"title_bar.background": "#09323d",
|
||||
"title_bar.border": "#16404b",
|
||||
"base.red": "#7d2f0d",
|
||||
"base.green": "#0d7d3f",
|
||||
"base.yellow": "#7d650d",
|
||||
"base.blue": "#0d507d",
|
||||
"base.magenta": "#7d0d43",
|
||||
"base.cyan": "#0d7d70"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#002B36",
|
||||
"editor.active_line.background": "#073642",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#93A1A166",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#802207",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0C194D",
|
||||
"info.border": "#082190",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"name": "Spaceduck",
|
||||
"author": "Guillermo Rodriguez",
|
||||
"url": "https://github.com/pineapplegiant/spaceduck",
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Spaceduck",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#212440",
|
||||
"accent.foreground": "#a3a49d",
|
||||
"background": "#0F111B",
|
||||
"border": "#272C42",
|
||||
"foreground": "#a3a49d",
|
||||
"input.border": "#272C42",
|
||||
"link.active.foreground": "#089CC5",
|
||||
"link.foreground": "#089CC5",
|
||||
"link.hover.foreground": "#089CC5",
|
||||
"list.active.background": "#089CC511",
|
||||
"list.active.border": "#089CC5",
|
||||
"list.even.background": "#161928",
|
||||
"muted.background": "#272C4255",
|
||||
"muted.foreground": "#4b6479",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#0F111B",
|
||||
"popover.foreground": "#a3a49d",
|
||||
"primary.background": "#089CC5",
|
||||
"primary.foreground": "#ecf0c1",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#24303a",
|
||||
"secondary.background": "#242547",
|
||||
"secondary.active.background": "#292b51",
|
||||
"secondary.foreground": "#a3a49d",
|
||||
"secondary.hover.background": "#242547",
|
||||
"tab.active.background": "#0F111B",
|
||||
"tab.foreground": "#838182",
|
||||
"title_bar.background": "#141724",
|
||||
"title_bar.border": "#272C42",
|
||||
"base.red": "#e33400",
|
||||
"base.green": "#5ccc96",
|
||||
"base.blue": "#00a3cc",
|
||||
"base.cyan": "#089CC5",
|
||||
"base.magenta": "#C86D8C",
|
||||
"base.magenta.light": "#C86D8C33",
|
||||
"base.yellow": "#b89c00"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#ecf0c1",
|
||||
"editor.background": "#0F111B",
|
||||
"editor.active_line.background": "#1c1d39",
|
||||
"editor.line_number": "#4b6479",
|
||||
"editor.active_line_number": "#ecf0c1",
|
||||
"editor.invisible": "#4b647966",
|
||||
"conflict": "#e33400",
|
||||
"created": "#5ccc96",
|
||||
"deleted": "#e33400",
|
||||
"error": "#e33400",
|
||||
"hidden": "#4b6479",
|
||||
"hint": "#C86D8C",
|
||||
"ignored": "#4b6479",
|
||||
"info": "#089CC5",
|
||||
"modified": "#f2ce00",
|
||||
"predictive": "#5D5945",
|
||||
"renamed": "#089CC5",
|
||||
"success": "#5ccc96",
|
||||
"unreachable": "#4b6479",
|
||||
"warning": "#f2ce00",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#4b6479",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#4b6479",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#e33400"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#5ccc96"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#ecf0c1"
|
||||
},
|
||||
"function": {
|
||||
"color": "#089CC5"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#C86D8C"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#089CC5",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#089CC5",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"string": {
|
||||
"color": "#5ccc96"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#5ccc96"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#5ccc96"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#C86D8C"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#ecf0c1"
|
||||
},
|
||||
"title": {
|
||||
"color": "#f2ce00",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#089CC5"
|
||||
},
|
||||
"property": {
|
||||
"color": "#ecf0c1"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#C86D8C"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
{
|
||||
"name": "Tokyo",
|
||||
"author": "Folke Lemaitre",
|
||||
"url": "https://github.com/folke/tokyonight.nvim",
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Tokyo Night",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2C3045",
|
||||
"accent.foreground": "#c0caf5",
|
||||
"background": "#1a1b26",
|
||||
"border": "#292e42",
|
||||
"window_border": "#3d4462",
|
||||
"ring": "#7aa2f7",
|
||||
"foreground": "#c0caf5",
|
||||
"link.active.foreground": "#7aa2f722",
|
||||
"link.foreground": "#7aa2f7",
|
||||
"link.hover.foreground": "#7aa2f7",
|
||||
"list.active.background": "#7aa2f722",
|
||||
"list.active.border": "#7aa2f7",
|
||||
"list.even.background": "#2C304599",
|
||||
"muted.background": "#2C304544",
|
||||
"muted.foreground": "#565f89",
|
||||
"panel.background": "#292e42",
|
||||
"popover.background": "#1a1b26",
|
||||
"popover.foreground": "#c0caf5",
|
||||
"primary.background": "#7aa2f7",
|
||||
"primary.foreground": "#1a1b26",
|
||||
"scrollbar.background": "#1a1b2600",
|
||||
"scrollbar.thumb.background": "#414868",
|
||||
"secondary.active.background": "#292e42",
|
||||
"secondary.background": "#292e42",
|
||||
"secondary.foreground": "#C0CAF5",
|
||||
"secondary.hover.background": "#2d3349",
|
||||
"title_bar.background": "#161720",
|
||||
"title_bar.border": "#292e42",
|
||||
"chart.grid": "#222332",
|
||||
"base.red": "#f7768e",
|
||||
"base.green": "#9ece6a",
|
||||
"base.yellow": "#e0af68",
|
||||
"base.blue": "#7aa2f7",
|
||||
"base.magenta": "#565f89",
|
||||
"base.cyan": "#7dcfff"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#c0caf5",
|
||||
"editor.background": "#1a1b26",
|
||||
"editor.active_line.background": "#292e42",
|
||||
"editor.line_number": "#565f89",
|
||||
"editor.active_line_number": "#c0caf5",
|
||||
"editor.invisible": "#565f8966",
|
||||
"conflict": "#f7768e",
|
||||
"created": "#9ece6a",
|
||||
"deleted": "#f7768e",
|
||||
"error": "#f7768e",
|
||||
"hidden": "#565f89",
|
||||
"hint": "#7dcfff",
|
||||
"ignored": "#565f89",
|
||||
"info": "#7aa2f7",
|
||||
"modified": "#e0af68",
|
||||
"predictive": "#565f89",
|
||||
"renamed": "#7aa2f7",
|
||||
"success": "#9ece6a",
|
||||
"unreachable": "#565f89",
|
||||
"warning": "#e0af68",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#9ece6a"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#565f89",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#565f89",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#7aa2f7"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#7aa2f7"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#f7768e"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#7dcfff",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#7aa2f7",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"string": {
|
||||
"color": "#9ece6a"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#7dcfff"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#9ece6a"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#f7768e"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#7aa2f7",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#7dcfff"
|
||||
},
|
||||
"property": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#f7768e"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Tokyo Storm",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#414868",
|
||||
"accent.foreground": "#c0caf5",
|
||||
"background": "#24283b",
|
||||
"border": "#3d4462",
|
||||
"window_border": "#3d4462",
|
||||
"ring": "#7aa2f7",
|
||||
"foreground": "#c0caf5",
|
||||
"input.border": "#414868",
|
||||
"link.active.foreground": "#7aa2f7",
|
||||
"link.foreground": "#7aa2f7",
|
||||
"link.hover.foreground": "#7aa2f7",
|
||||
"list.active.background": "#7aa2f711",
|
||||
"list.active.border": "#7aa2f7",
|
||||
"list.even.background": "#41486844",
|
||||
"muted.background": "#292e42",
|
||||
"muted.foreground": "#565f89",
|
||||
"panel.background": "#292e42",
|
||||
"popover.background": "#24283b",
|
||||
"popover.foreground": "#c0caf5",
|
||||
"primary.background": "#7aa2f7",
|
||||
"primary.foreground": "#24283b",
|
||||
"scrollbar.background": "#24283b00",
|
||||
"scrollbar.thumb.background": "#414868",
|
||||
"secondary.active.background": "#323750",
|
||||
"secondary.background": "#292e42",
|
||||
"secondary.foreground": "#c0caf5",
|
||||
"secondary.hover.background": "#32375099",
|
||||
"title_bar.background": "#202435",
|
||||
"title_bar.border": "#3d4462",
|
||||
"base.red": "#f7768e",
|
||||
"base.green": "#9ece6a",
|
||||
"base.yellow": "#e0af68",
|
||||
"base.blue": "#7aa2f7",
|
||||
"base.magenta": "#b283f8",
|
||||
"base.cyan": "#7dcfff"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#B0B9E2",
|
||||
"editor.background": "#24283B",
|
||||
"editor.active_line.background": "#292E42",
|
||||
"editor.line_number": "#363C58",
|
||||
"editor.active_line_number": "#B0B9E2",
|
||||
"editor.invisible": "#565f8966",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"deleted": "#f7768e",
|
||||
"error": "#f7768e",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"success": "#9ece6a",
|
||||
"warning": "#e0af68",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Tokyo Moon",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#32364E",
|
||||
"accent.foreground": "#c0caf5",
|
||||
"background": "#222436",
|
||||
"border": "#323752",
|
||||
"window_border": "#404668",
|
||||
"ring": "#82aaff",
|
||||
"foreground": "#c0caf5",
|
||||
"input.border": "#444b6a",
|
||||
"link.active.foreground": "#82aaff",
|
||||
"link.foreground": "#82aaff",
|
||||
"link.hover.foreground": "#82aaff",
|
||||
"list.active.background": "#82aaff22",
|
||||
"list.active.border": "#82aaff",
|
||||
"list.background": "#222436",
|
||||
"list.even.background": "#282a40",
|
||||
"muted.background": "#2d3149",
|
||||
"muted.foreground": "#6e738d",
|
||||
"panel.background": "#2d3149",
|
||||
"popover.background": "#222436",
|
||||
"popover.foreground": "#c0caf5",
|
||||
"primary.background": "#82aaff",
|
||||
"primary.foreground": "#222436",
|
||||
"scrollbar.background": "#22243600",
|
||||
"scrollbar.thumb.background": "#444b6a",
|
||||
"secondary.active.background": "#2f334c",
|
||||
"secondary.background": "#2d3149",
|
||||
"secondary.foreground": "#8c94b5",
|
||||
"secondary.hover.background": "#31354f",
|
||||
"table.row.border": "#2d3149",
|
||||
"title_bar.background": "#1e2030",
|
||||
"title_bar.border": "#323752",
|
||||
"base.red": "#ff757f",
|
||||
"base.green": "#c3e88d",
|
||||
"base.yellow": "#ffc777",
|
||||
"base.blue": "#82aaff",
|
||||
"base.magenta": "#6e738d",
|
||||
"base.cyan": "#86e1fc"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#c0caf5",
|
||||
"editor.background": "#222436",
|
||||
"editor.active_line.background": "#2d3149",
|
||||
"editor.line_number": "#6e738d",
|
||||
"editor.active_line_number": "#c0caf5",
|
||||
"editor.invisible": "#565f8966",
|
||||
"conflict": "#ed8796",
|
||||
"created": "#c3e88d",
|
||||
"deleted": "#ed8796",
|
||||
"error": "#ed8796",
|
||||
"hidden": "#6e738d",
|
||||
"hint": "#86e1fc",
|
||||
"ignored": "#6e738d",
|
||||
"info": "#82aaff",
|
||||
"modified": "#ffc777",
|
||||
"predictive": "#6e738d",
|
||||
"renamed": "#82aaff",
|
||||
"success": "#c3e88d",
|
||||
"unreachable": "#6e738d",
|
||||
"warning": "#ffc777",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#c3e88d"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#6e738d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#6e738d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#82aaff"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#82aaff"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#ed8796"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#86e1fc",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#82aaff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"string": {
|
||||
"color": "#c3e88d"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#86e1fc"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#c3e88d"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#ed8796"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#82aaff",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#86e1fc"
|
||||
},
|
||||
"property": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#ed8796"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Twilight",
|
||||
"author": "MacroMates",
|
||||
"url": "https://macromates.com",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Twilight",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2b2b2b",
|
||||
"accent.foreground": "#dcdcdc",
|
||||
"background": "#141414",
|
||||
"border": "#343434",
|
||||
"foreground": "#dcdcdc",
|
||||
"input.border": "#3e3e3e",
|
||||
"list.active.background": "#CDA86911",
|
||||
"list.active.border": "#CDA86988",
|
||||
"list.even.background": "#1E1E1E99",
|
||||
"muted.background": "#2e2e2e88",
|
||||
"muted.foreground": "#828282",
|
||||
"primary.background": "#CDA869",
|
||||
"primary.foreground": "#1e1e1e",
|
||||
"scrollbar.background": "#1e1e1e00",
|
||||
"scrollbar.thumb.background": "#3e3e3e",
|
||||
"secondary.background": "#262626",
|
||||
"secondary.active.background": "#2a2a2a",
|
||||
"secondary.foreground": "#dcdcdc",
|
||||
"secondary.hover.background": "#272727",
|
||||
"tab.active.background": "#141414",
|
||||
"tab.active.foreground": "#dcdcdc",
|
||||
"tab.background": "#1E1E1E00",
|
||||
"tab.foreground": "#A9A9A9",
|
||||
"tab_bar.background": "#1E1E1E",
|
||||
"table.background": "#1e1e1e00",
|
||||
"title_bar.background": "#1e1e1e",
|
||||
"title_bar.border": "#343434",
|
||||
"base.red": "#c06d44",
|
||||
"base.red.light": "#de7c4c",
|
||||
"base.green": "#afb97a",
|
||||
"base.green.light": "#ccd88c",
|
||||
"base.yellow": "#c2a86c",
|
||||
"base.yellow.light": "#e2c47e",
|
||||
"base.blue": "#44474a",
|
||||
"base.blue.light": "#5a5e62",
|
||||
"base.magenta": "#b4be7c",
|
||||
"base.magenta.light": "#d0dc8e99",
|
||||
"base.cyan": "#778385",
|
||||
"base.cyan.light": "#8a989b"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#9E9E9E66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,6 +10,9 @@ anyhow.workspace = true
|
||||
rust-embed.workspace = true
|
||||
gpui-kit.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.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]
|
||||
|
||||
@@ -8,6 +8,7 @@ use rust_embed::RustEmbed;
|
||||
#[include = "fonts/**/*"]
|
||||
#[include = "brand/**/*"]
|
||||
#[include = "icons/**/*"]
|
||||
#[include = "themes/**/*"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct Assets;
|
||||
|
||||
@@ -74,3 +75,29 @@ impl AssetSource for Assets {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui_kit::component::ThemeSet;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Every bundled theme file must parse against gpui-kit's schema.
|
||||
#[test]
|
||||
fn bundled_themes_parse() {
|
||||
let mut count = 0;
|
||||
|
||||
for entry in Assets::iter().filter(|p| p.starts_with("themes/")) {
|
||||
let file = Assets::get(&entry).expect("theme file exists");
|
||||
let content = std::str::from_utf8(&file.data).expect("theme is UTF-8");
|
||||
let set: ThemeSet = serde_json::from_str(content).expect("theme parses");
|
||||
assert!(!set.themes.is_empty(), "{entry} has no themes");
|
||||
count += set.themes.len();
|
||||
}
|
||||
|
||||
assert!(
|
||||
count >= 36,
|
||||
"expected the bundled gpui-kit themes, got {count}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ impl AutoUpdater {
|
||||
pub fn status(&self) -> SharedString {
|
||||
match &self.status {
|
||||
UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".into(),
|
||||
UpdateStatus::Checking => "Checking for updates…".into(),
|
||||
UpdateStatus::Checking => "Checking for updates".into(),
|
||||
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
||||
UpdateStatus::Downloading { downloaded, total } => {
|
||||
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
||||
@@ -165,7 +165,7 @@ impl AutoUpdater {
|
||||
None => format!("Downloading {downloaded_mb:.1} MB").into(),
|
||||
}
|
||||
}
|
||||
UpdateStatus::Installing => "Installing update…".into(),
|
||||
UpdateStatus::Installing => "Installing update".into(),
|
||||
UpdateStatus::Staged(version) => {
|
||||
format!("Version {version} ready — restart to apply").into()
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
|
||||
<div id="nip07-status" class="status status--checking">
|
||||
<div class="status__dot"></div>
|
||||
<span id="nip07-status-text">Checking extension…</span>
|
||||
<span id="nip07-status-text">Checking extension</span>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
|
||||
@@ -344,9 +344,10 @@ impl ChatRegistry {
|
||||
}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(id)
|
||||
if (id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2) =>
|
||||
{
|
||||
RelayMessage::EndOfStoredEvents {
|
||||
subscription_id: id,
|
||||
..
|
||||
} if (id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2) => {
|
||||
tx.send_async(Signal::Eose).await?;
|
||||
}
|
||||
_ => {}
|
||||
@@ -947,7 +948,11 @@ async fn extract_rumor(
|
||||
let Some(device_signer) = device_signer else {
|
||||
return Err(error);
|
||||
};
|
||||
try_unwrap_with(device_signer, gift_wrap).await?
|
||||
match try_unwrap_with(device_signer, gift_wrap).await {
|
||||
Ok(unwrapped) => unwrapped,
|
||||
// The wrap may be sealed to a key retired by a rotation
|
||||
Err(_) => try_unwrap_archived(client, signer, gift_wrap).await?,
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut rumor = unwrapped.rumor;
|
||||
@@ -1003,6 +1008,25 @@ async fn try_unwrap_with(
|
||||
})
|
||||
}
|
||||
|
||||
/// Unwrap with an encryption key retired by a rotation; peers may still seal
|
||||
/// to the previous key until their announcement cache refreshes
|
||||
async fn try_unwrap_archived(
|
||||
client: &Client,
|
||||
signer: &UniversalSigner,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnwrappedGift, Error> {
|
||||
for keys in DeviceRegistry::archived_keys(client, signer).await? {
|
||||
let archived = UniversalSigner::new(keys);
|
||||
if let Ok(unwrapped) = try_unwrap_with(&archived, gift_wrap).await {
|
||||
return Ok(unwrapped);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"No archived encryption key could unwrap the gift wrap"
|
||||
))
|
||||
}
|
||||
|
||||
/// Stores an unwrapped event in local database with reference to original
|
||||
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
||||
let room_id = rumor.uniq_id().to_string();
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
|
||||
|
||||
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
|
||||
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key yet. Set one up in the settings to message them.";
|
||||
const KEY_STALE: &str = "Your encryption key is out of date; sync it from another device in the settings to keep messaging.";
|
||||
|
||||
/// The outcome of delivering an encrypted rumor to a single receiver.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -553,6 +554,7 @@ impl Room {
|
||||
let config = self.config.clone();
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let resyncing = device.read(cx).is_resyncing();
|
||||
let encryption_signer = device.read(cx).signer(cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -574,6 +576,28 @@ impl Room {
|
||||
Some(cx.background_spawn(async move {
|
||||
let signer_kind = config.signer_kind();
|
||||
|
||||
// Block sends while the local encryption key is stale against the
|
||||
// current announcement; the replacement is being synced from
|
||||
// another device
|
||||
if resyncing && matches!(signer_kind, SignerKind::Auto | SignerKind::Encryption) {
|
||||
let mut reports: Vec<SendReport> = receivers
|
||||
.into_iter()
|
||||
.map(|receiver| SendReport {
|
||||
receiver: receiver.public_key(),
|
||||
result: Err(KEY_STALE.into()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if config.backup() {
|
||||
reports.push(SendReport {
|
||||
receiver: sender.public_key(),
|
||||
result: Err(KEY_STALE.into()),
|
||||
});
|
||||
}
|
||||
|
||||
return reports;
|
||||
}
|
||||
|
||||
// Deliver to every receiver concurrently
|
||||
let sends = receivers.into_iter().map(|receiver| {
|
||||
deliver(
|
||||
|
||||
@@ -10,6 +10,7 @@ speech = { path = "../speech" }
|
||||
ui = { path = "../ui" }
|
||||
gpui-kit = { workspace = true, features = ["speech"] }
|
||||
common = { path = "../common" }
|
||||
log.workspace = true
|
||||
person = { path = "../person" }
|
||||
chat = { path = "../chat" }
|
||||
settings = { path = "../settings" }
|
||||
@@ -23,3 +24,6 @@ smallvec.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
regex = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
nip60_ui = { path = "../nip60_ui" }
|
||||
|
||||
@@ -12,13 +12,14 @@ use gpui::{
|
||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||
ObjectFit, ParentElement, Render, SharedString, SharedUri, StatefulInteractiveElement, Styled,
|
||||
StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, WeakEntity,
|
||||
Window, div, img, list, px, red, relative, retain_all, svg, white,
|
||||
Window, div, img, list, px, relative, rems, retain_all, svg,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||
use gpui_kit::component::menu::DropdownMenu;
|
||||
use gpui_kit::component::menu::{ContextMenuExt as _, DropdownMenu, PopupMenuItem};
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::scroll::Scrollbar;
|
||||
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
|
||||
@@ -30,13 +31,14 @@ use person::{Directory, Person};
|
||||
use regex::Regex;
|
||||
use settings::{AppSettings, SignerKind};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use speech::speaker::{END_GRACE, Speaker};
|
||||
use state::{
|
||||
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,
|
||||
Avatar, COMPACT_REACTION_EMOJIS, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS,
|
||||
ReactionBar, RenderedText, ReplyPreview, RevealText, WelcomeMessage, group_reactions,
|
||||
opens_run, react_menu, rendered_text,
|
||||
};
|
||||
|
||||
use crate::file::*;
|
||||
@@ -45,8 +47,7 @@ use crate::file::*;
|
||||
const PRIVATE_NOTICE: &str =
|
||||
"This conversation is private. Only members can see each other's messages.";
|
||||
|
||||
/// Regex matching strings that consist entirely of emoji characters,
|
||||
/// zero-width joiners, variation selectors, and keycap combiners.
|
||||
/// Regex matching strings that consist entirely of emoji characters.
|
||||
static EMOJI_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
||||
|
||||
@@ -65,65 +66,49 @@ pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity
|
||||
pub struct ChatPanel {
|
||||
id: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Chat Room
|
||||
room: WeakEntity<Room>,
|
||||
|
||||
/// Person directory shared with the room
|
||||
directory: Entity<Directory>,
|
||||
|
||||
/// Message list state
|
||||
list_state: ListState,
|
||||
|
||||
/// All messages (sorted by created_at)
|
||||
messages: Vec<Message>,
|
||||
|
||||
/// O(1) message lookup by EventId
|
||||
message_index: HashMap<EventId, usize>,
|
||||
|
||||
/// All reactions
|
||||
reactions: BTreeMap<EventId, Vec<(SharedString, Option<SharedUri>, PublicKey)>>,
|
||||
|
||||
/// Mapping message ids to their rendered texts
|
||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||
|
||||
/// Mapping message (rumor event) ids to their reports
|
||||
reports_by_id: Arc<RwLock<BTreeMap<EventId, Vec<SendReport>>>>,
|
||||
|
||||
/// Chat input state
|
||||
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>,
|
||||
|
||||
/// Reads messages aloud
|
||||
speaker: Speaker,
|
||||
/// The message being revealed in step with speech, with its estimated duration
|
||||
speech_reveal: Option<(EventId, Duration)>,
|
||||
/// Fallback reveal end for backends that cannot report utterance end
|
||||
reveal_timeout: Option<Task<Result<(), Error>>>,
|
||||
/// Subject input state
|
||||
subject_input: Entity<InputState>,
|
||||
|
||||
/// Subject bar visibility
|
||||
subject_bar: Entity<bool>,
|
||||
|
||||
/// Replies to
|
||||
replies_to: Entity<HashSet<EventId>>,
|
||||
|
||||
/// Media Attachment
|
||||
attachments: Entity<Vec<Url>>,
|
||||
|
||||
/// Uploaded, encrypted file attachments which are not sent yet
|
||||
encrypted_attachments: Entity<Vec<PendingFile>>,
|
||||
|
||||
/// Decrypted attachments of file messages, by message id
|
||||
decrypted_files: HashMap<EventId, DecryptedFile>,
|
||||
|
||||
/// Upload state
|
||||
uploading: bool,
|
||||
|
||||
/// Async operations
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
/// Event subscriptions
|
||||
subscriptions: SmallVec<[Subscription; 4]>,
|
||||
}
|
||||
|
||||
@@ -241,6 +226,20 @@ impl ChatPanel {
|
||||
this.get_messages(window, cx);
|
||||
});
|
||||
|
||||
// End the speech reveal when the utterance finishes ahead of its estimate
|
||||
let speaker = Speaker::new();
|
||||
let end_events = speaker.end_events();
|
||||
let reveal_end_task = cx.spawn(async move |this, cx| {
|
||||
while end_events.recv_async().await.is_ok() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.speech_reveal = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
id,
|
||||
@@ -253,6 +252,9 @@ impl ChatPanel {
|
||||
input,
|
||||
speech,
|
||||
speech_started: None,
|
||||
speaker,
|
||||
speech_reveal: None,
|
||||
reveal_timeout: None,
|
||||
subject_input,
|
||||
subject_bar,
|
||||
replies_to,
|
||||
@@ -263,7 +265,7 @@ impl ChatPanel {
|
||||
reports_by_id,
|
||||
uploading: false,
|
||||
subscriptions,
|
||||
tasks: vec![],
|
||||
tasks: vec![reveal_end_task],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,6 +675,36 @@ impl ChatPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a message aloud, tracking its reveal state until utterance end.
|
||||
fn speak_message(&mut self, id: &EventId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(text) = self.message(id).map(|message| message.preview()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match self.speaker.speak(&text) {
|
||||
Ok(duration) => {
|
||||
self.speech_reveal = Some((*id, duration));
|
||||
|
||||
// Re-enable speaking when no end event ever arrives
|
||||
let session = (*id, duration);
|
||||
let timer = cx.background_executor().timer(duration + END_GRACE);
|
||||
self.reveal_timeout = Some(cx.spawn(async move |this, cx| {
|
||||
timer.await;
|
||||
this.update(cx, |this, cx| {
|
||||
if this.speech_reveal.as_ref() == Some(&session) {
|
||||
this.speech_reveal = None;
|
||||
cx.notify();
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) => window.push_notification(Notification::error(error.to_string()), cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_reply(&mut self, id: &EventId, cx: &mut Context<Self>) {
|
||||
self.replies_to.update(cx, |this, cx| {
|
||||
this.remove(id);
|
||||
@@ -680,6 +712,41 @@ impl ChatPanel {
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the nutzap dialog for the room's peer; direct chats only
|
||||
fn open_nutzap(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = (window, cx);
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let Some(room) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let peers: Vec<PublicKey> = room
|
||||
.read(cx)
|
||||
.members()
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| *key != me)
|
||||
.collect();
|
||||
|
||||
if let [recipient] = peers[..] {
|
||||
nip60_ui::open_nutzap(window, cx, recipient, None);
|
||||
} else {
|
||||
window.push_notification(
|
||||
Notification::warning("Nutzaps can only be sent in direct chats"),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Get the user's configured blossom server
|
||||
let server = AppSettings::get_file_server(cx);
|
||||
@@ -990,7 +1057,9 @@ impl ChatPanel {
|
||||
.bg(cx.theme().secondary)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.child(div().size_1p5().rounded_full().bg(gpui::green()))
|
||||
.child(
|
||||
div().size_1p5().rounded_full().bg(cx.theme().success),
|
||||
)
|
||||
.child(SharedString::from(url.to_string())),
|
||||
);
|
||||
}
|
||||
@@ -1008,7 +1077,7 @@ impl ChatPanel {
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
let relays = profile.messaging_relays();
|
||||
|
||||
this.title("Messaging Relays")
|
||||
this.title("Messaging relays")
|
||||
.close_button(true)
|
||||
.child(v_flex().gap_1().children({
|
||||
let mut items = vec![];
|
||||
@@ -1022,7 +1091,7 @@ impl ChatPanel {
|
||||
.bg(cx.theme().secondary)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.child(div().size_1p5().rounded_full().bg(gpui::green()))
|
||||
.child(div().size_1p5().rounded_full().bg(cx.theme().success))
|
||||
.child(SharedString::from(url.to_string())),
|
||||
);
|
||||
}
|
||||
@@ -1065,7 +1134,7 @@ impl ChatPanel {
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().list_active),
|
||||
.text_color(cx.theme().accent),
|
||||
)
|
||||
.child(PRIVATE_NOTICE)
|
||||
.into_any_element()
|
||||
@@ -1090,7 +1159,7 @@ impl ChatPanel {
|
||||
.rounded_full()
|
||||
.bg(cx.theme().warning.opacity(0.15))
|
||||
.text_color(cx.theme().warning)
|
||||
.child(Icon::new(IconName::Warning).small()),
|
||||
.child(Icon::new(IconName::TriangleAlert).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -1159,7 +1228,11 @@ impl ChatPanel {
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.element(ix.into(), window, cx);
|
||||
.element(
|
||||
SharedString::from(format!("text-{}", message.id.to_hex())).into(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
self.render_text_message(ix, message, text, show_author, cx)
|
||||
} else {
|
||||
@@ -1169,7 +1242,7 @@ impl ChatPanel {
|
||||
|
||||
fn render_text_message(
|
||||
&self,
|
||||
ix: usize,
|
||||
_ix: usize,
|
||||
message: &Message,
|
||||
rendered_text: AnyElement,
|
||||
show_author: bool,
|
||||
@@ -1192,7 +1265,16 @@ impl ChatPanel {
|
||||
nostr.read(cx).current_user(),
|
||||
));
|
||||
|
||||
MessageRow::new(ix)
|
||||
let body = match self
|
||||
.speech_reveal
|
||||
.as_ref()
|
||||
.filter(|(reveal_id, _)| *reveal_id == id)
|
||||
{
|
||||
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
|
||||
None => rendered_text,
|
||||
};
|
||||
|
||||
let row = MessageRow::new(SharedString::from(format!("message-{}", id.to_hex())))
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
@@ -1216,22 +1298,12 @@ impl ChatPanel {
|
||||
.when(has_replies, |this| {
|
||||
this.children(self.render_message_replies(replies, cx))
|
||||
})
|
||||
.when(message.file.is_none(), |this| this.child(rendered_text))
|
||||
.when(message.file.is_none(), |this| this.child(body))
|
||||
.child(MessageMedia::new(&message.media))
|
||||
.when_some(message.file.as_ref(), |this, file| {
|
||||
this.child(self.render_message_file(&id, file, cx))
|
||||
})
|
||||
.when(has_reactions, |this| this.child(reactions))
|
||||
.overlay(
|
||||
div()
|
||||
.group_hover("", |this| this.bg(cx.theme().list_active))
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(gpui::transparent_black()),
|
||||
)
|
||||
.overlay(self.render_actions(&id, &pk, cx))
|
||||
.on_mouse_down(
|
||||
MouseButton::Middle,
|
||||
@@ -1241,7 +1313,76 @@ impl ChatPanel {
|
||||
)
|
||||
.on_double_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.reply_to(&id, cx);
|
||||
}))
|
||||
}));
|
||||
|
||||
let panel = cx.weak_entity();
|
||||
let speaking = self.speech_reveal.is_some();
|
||||
|
||||
div()
|
||||
.id(SharedString::from(format!("message-menu-{}", id.to_hex())))
|
||||
.w_full()
|
||||
.child(row)
|
||||
.context_menu(move |menu, window, cx| {
|
||||
let menu = menu.item({
|
||||
let panel = panel.clone();
|
||||
let id = id;
|
||||
|
||||
PopupMenuItem::new("Reply").on_click(move |_, _window, cx| {
|
||||
if let Err(error) = panel.update(cx, |panel, cx| {
|
||||
panel.reply_to(&id, cx);
|
||||
}) {
|
||||
log::warn!("starting a reply failed: {error}");
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let menu = menu.submenu("React", window, cx, {
|
||||
let panel = panel.clone();
|
||||
|
||||
move |menu, _window, _cx| {
|
||||
let panel = panel.clone();
|
||||
|
||||
react_menu(menu, REACTION_EMOJIS, &[], move |emoji, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.send_reaction(emoji, &id, window, cx);
|
||||
}) {
|
||||
log::warn!("sending a reaction failed: {error}");
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
menu.item({
|
||||
let panel = panel.clone();
|
||||
let id = id;
|
||||
|
||||
PopupMenuItem::new("Copy message").on_click(move |_, _window, cx| {
|
||||
if let Err(error) = panel.update(cx, |panel, cx| {
|
||||
panel.copy_message(&id, cx);
|
||||
}) {
|
||||
log::warn!("copying a message failed: {error}");
|
||||
}
|
||||
})
|
||||
})
|
||||
.item({
|
||||
let panel = panel.clone();
|
||||
let id = id;
|
||||
let speaking = speaking;
|
||||
|
||||
PopupMenuItem::new("Read aloud")
|
||||
.disabled(speaking)
|
||||
.on_click(move |_, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.speak_message(&id, window, cx);
|
||||
}) {
|
||||
log::warn!("reading the message aloud failed: {error}");
|
||||
}
|
||||
})
|
||||
})
|
||||
.separator()
|
||||
.menu("Copy author", Box::new(Command::Copy(pk)))
|
||||
.menu("Seen on", Box::new(Command::Trace(id)))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -1252,14 +1393,19 @@ impl ChatPanel {
|
||||
) -> impl IntoIterator<Item = impl IntoElement> {
|
||||
let mut items = Vec::with_capacity(replies.len());
|
||||
|
||||
for (ix, id) in replies.iter().enumerate() {
|
||||
for id in replies.iter() {
|
||||
let Some(message) = self.message(id) else {
|
||||
continue;
|
||||
};
|
||||
let author = self.profile(&message.author, cx);
|
||||
|
||||
items.push(
|
||||
ReplyPreview::new(ix, author.name(), message.preview()).on_click({
|
||||
ReplyPreview::new(
|
||||
SharedString::from(format!("reply-{}", id.to_hex())),
|
||||
author.name(),
|
||||
message.preview(),
|
||||
)
|
||||
.on_click({
|
||||
let id = *id;
|
||||
cx.listener(move |this, _event, _window, _cx| {
|
||||
this.scroll_to(&id);
|
||||
@@ -1290,7 +1436,7 @@ impl ChatPanel {
|
||||
} else if failed {
|
||||
SharedString::from("• Error")
|
||||
} else if pending {
|
||||
SharedString::from("• Sending...")
|
||||
SharedString::from("• Sending")
|
||||
} else {
|
||||
SharedString::from("• Unknown")
|
||||
};
|
||||
@@ -1315,7 +1461,7 @@ impl ChatPanel {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
this.title(SharedString::from("Sent Reports"))
|
||||
this.title(SharedString::from("Sent reports"))
|
||||
.close_button(true)
|
||||
.child(v_flex().gap_4().children(items))
|
||||
});
|
||||
@@ -1464,6 +1610,16 @@ impl ChatPanel {
|
||||
actions
|
||||
};
|
||||
|
||||
let speaking = self.speech_reveal.is_some();
|
||||
let speech_id = *id;
|
||||
|
||||
let actions = actions.speech(
|
||||
speaking,
|
||||
cx.listener(move |this, _event, window, cx| {
|
||||
this.speak_message(&speech_id, window, cx);
|
||||
}),
|
||||
);
|
||||
|
||||
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)))
|
||||
@@ -1492,8 +1648,12 @@ impl ChatPanel {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(red())
|
||||
.child(Icon::new(IconName::Close).size_2().text_color(white())),
|
||||
.bg(cx.theme().danger)
|
||||
.child(
|
||||
Icon::new(IconName::Close)
|
||||
.size_2()
|
||||
.text_color(cx.theme().danger_foreground),
|
||||
),
|
||||
)
|
||||
.on_click({
|
||||
let url = url.clone();
|
||||
@@ -1538,7 +1698,7 @@ impl ChatPanel {
|
||||
img(local.image_source())
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.h(px(250.))
|
||||
.h(rems(16.))
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.rounded(cx.theme().radius_lg),
|
||||
)
|
||||
@@ -1573,7 +1733,7 @@ impl ChatPanel {
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(Icon::new(IconName::Lock).text_color(cx.theme().primary))
|
||||
.child(Icon::new(IconName::Lock).text_color(cx.theme().accent))
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
@@ -1645,8 +1805,12 @@ impl ChatPanel {
|
||||
.rounded_full()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(gpui::green())
|
||||
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())),
|
||||
.bg(cx.theme().success)
|
||||
.child(
|
||||
Icon::new(IconName::Lock)
|
||||
.size_2()
|
||||
.text_color(cx.theme().success_foreground),
|
||||
),
|
||||
)
|
||||
.on_click({
|
||||
let url = file.url.clone();
|
||||
@@ -1674,47 +1838,20 @@ impl ChatPanel {
|
||||
if let Some(text) = self.message(id) {
|
||||
let profile = self.directory.read(cx).person(&text.author, cx);
|
||||
|
||||
div()
|
||||
.w_full()
|
||||
.pl_2()
|
||||
.border_l_2()
|
||||
.border_color(cx.theme().list_active)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Replying to:")
|
||||
.child(div().text_color(cx.theme().primary).child(profile.name())),
|
||||
)
|
||||
.child(
|
||||
Button::new("remove-reply")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_click({
|
||||
let id = text.id;
|
||||
cx.listener(move |this, _, _, cx| {
|
||||
this.remove_reply(&id, cx);
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.text_ellipsis()
|
||||
.line_clamp(1)
|
||||
.child(text.preview()),
|
||||
)
|
||||
ReplyPreview::new(
|
||||
SharedString::from(format!("reply-{}", id.to_hex())),
|
||||
profile.name(),
|
||||
text.preview(),
|
||||
)
|
||||
.on_remove({
|
||||
let id = text.id;
|
||||
cx.listener(move |this, _, _, cx| {
|
||||
this.remove_reply(&id, cx);
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
} else {
|
||||
div()
|
||||
div().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1801,7 +1938,8 @@ impl ChatPanel {
|
||||
.unwrap_or_default();
|
||||
|
||||
Button::new("emoji")
|
||||
.icon(IconName::Emoji)
|
||||
.icon(IconName::FaceGrinning)
|
||||
.tooltip("Emoji")
|
||||
.ghost()
|
||||
.disabled(disabled)
|
||||
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
||||
@@ -1849,7 +1987,7 @@ impl Panel for ChatPanel {
|
||||
|
||||
Some(vec![
|
||||
Button::new("subject")
|
||||
.icon(IconName::Input)
|
||||
.icon(IconName::TextCursorInput)
|
||||
.tooltip("Change subject")
|
||||
.small()
|
||||
.ghost()
|
||||
@@ -1896,7 +2034,7 @@ impl Render for ChatPanel {
|
||||
.child(Input::new(&self.subject_input))
|
||||
.child(
|
||||
Button::new("change")
|
||||
.icon(IconName::CheckCircle)
|
||||
.icon(IconName::CircleCheck)
|
||||
.label("Change")
|
||||
.secondary()
|
||||
.disabled(self.uploading)
|
||||
@@ -1988,24 +2126,33 @@ impl Render for ChatPanel {
|
||||
.flex_1(),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.items_end()
|
||||
.gap_1()
|
||||
.pl_1()
|
||||
.child(SpeechButton::new(&self.speech)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.pl_1()
|
||||
.gap_1()
|
||||
.child(SpeechButton::new(&self.speech))
|
||||
.when(cfg!(not(target_arch = "wasm32")), |this| {
|
||||
this.child(
|
||||
Button::new("nutzap")
|
||||
.icon(IconName::Wallet)
|
||||
.tooltip("Send eCash")
|
||||
.disabled(dictating)
|
||||
.ghost()
|
||||
.on_click(cx.listener(
|
||||
move |this, _ev, window, cx| {
|
||||
this.open_nutzap(window, cx);
|
||||
},
|
||||
)),
|
||||
)
|
||||
})
|
||||
.child(self.render_emoji_menu(dictating, window, cx))
|
||||
.child(self.render_config_menu(dictating, window, cx))
|
||||
.child(
|
||||
Button::new("send")
|
||||
.icon(IconName::PaperPlaneFill)
|
||||
.icon(IconName::SendHorizontal)
|
||||
.tooltip("Send")
|
||||
.disabled(dictating || self.uploading)
|
||||
.ghost()
|
||||
.primary()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.send_text_message(window, cx);
|
||||
})),
|
||||
|
||||
@@ -1208,7 +1208,10 @@ impl GroupsRegistry {
|
||||
tx.send_async(signal).await.ok();
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(id) if is_own_subscription(&id) => {
|
||||
RelayMessage::EndOfStoredEvents {
|
||||
subscription_id: id,
|
||||
..
|
||||
} if is_own_subscription(&id) => {
|
||||
tx.send_async(Signal::Eose(id.into_owned())).await.ok();
|
||||
}
|
||||
RelayMessage::Closed {
|
||||
@@ -1222,7 +1225,7 @@ impl GroupsRegistry {
|
||||
RelayMessage::NegMsg { .. } => {
|
||||
tx.send_async(Signal::Syncing(relay_url)).await.ok();
|
||||
}
|
||||
RelayMessage::NegErr { .. } | RelayMessage::EndOfStoredEvents(_) => {
|
||||
RelayMessage::NegErr { .. } | RelayMessage::EndOfStoredEvents { .. } => {
|
||||
tx.send_async(Signal::Synced(relay_url)).await.ok();
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
use gpui::{
|
||||
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px,
|
||||
relative,
|
||||
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div,
|
||||
rems,
|
||||
};
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::description_list::{DescriptionItem, DescriptionList};
|
||||
use gpui_kit::component::tag::Tag;
|
||||
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
|
||||
use nip29::{Group, GroupMetadata};
|
||||
use ui::Avatar;
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
this.max_h(px(600.))
|
||||
this.max_h(rems(37.5))
|
||||
.close_button(true)
|
||||
.title("About Group")
|
||||
.title("About group")
|
||||
.child(body(&group, cx))
|
||||
});
|
||||
}
|
||||
@@ -23,6 +25,7 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
|
||||
let group = entity.read(cx);
|
||||
let mut body = v_flex().gap_3().text_xs().child(header(group, cx));
|
||||
let mut items = Vec::new();
|
||||
|
||||
if let Some(metadata) = group.metadata() {
|
||||
if let Some(about) = metadata.about.as_ref() {
|
||||
@@ -36,86 +39,100 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
let flags = flags(metadata);
|
||||
|
||||
if !flags.is_empty() {
|
||||
body = body.child(section(
|
||||
"Flags",
|
||||
wrap(flags.into_iter().map(|f| chip(f, cx))),
|
||||
cx,
|
||||
));
|
||||
items.push(
|
||||
DescriptionItem::new("Flags").value(wrap(
|
||||
flags
|
||||
.into_iter()
|
||||
.map(|flag| Tag::secondary().child(flag).into_any_element()),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(parent) = metadata.parent.as_ref() {
|
||||
body = body.child(section(
|
||||
"Parent",
|
||||
div()
|
||||
.child(SharedString::from(parent.as_str().to_owned()))
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
items.push(
|
||||
DescriptionItem::new("Parent").value(
|
||||
div()
|
||||
.child(SharedString::from(parent.as_str().to_owned()))
|
||||
.into_any_element(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !metadata.children.is_empty() {
|
||||
body = body.child(section(
|
||||
"Children",
|
||||
wrap(
|
||||
metadata
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| chip(child.as_str().to_owned(), cx)),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
items.push(
|
||||
DescriptionItem::new("Children").value(wrap(metadata.children.iter().map(
|
||||
|child| {
|
||||
Tag::secondary()
|
||||
.child(SharedString::from(child.as_str().to_owned()))
|
||||
.into_any_element()
|
||||
},
|
||||
))),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(kinds) = metadata.supported_kinds.as_ref() {
|
||||
let items: Vec<AnyElement> = kinds
|
||||
let values: Vec<AnyElement> = kinds
|
||||
.iter()
|
||||
.map(|kind| chip(kind.to_string(), cx))
|
||||
.map(|kind| {
|
||||
Tag::secondary()
|
||||
.child(SharedString::from(kind.to_string()))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect();
|
||||
|
||||
body = body.child(section(
|
||||
"Supported kinds",
|
||||
if items.is_empty() {
|
||||
items.push(
|
||||
DescriptionItem::new("Supported kinds").value(if values.is_empty() {
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("None")
|
||||
.into_any_element()
|
||||
} else {
|
||||
wrap(items)
|
||||
},
|
||||
cx,
|
||||
));
|
||||
wrap(values)
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let roles = group.roles().roles();
|
||||
if !roles.is_empty() {
|
||||
body = body.child(section(
|
||||
"Roles",
|
||||
wrap(roles.iter().map(|role| chip(role.name.clone(), cx))),
|
||||
cx,
|
||||
));
|
||||
items.push(
|
||||
DescriptionItem::new("Roles").value(wrap(roles.iter().map(|role| {
|
||||
Tag::secondary()
|
||||
.child(SharedString::from(role.name.clone()))
|
||||
.into_any_element()
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
let pins = group.pins().pins();
|
||||
if !pins.is_empty() {
|
||||
body = body.child(section(
|
||||
"Pinned",
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(
|
||||
pins.iter()
|
||||
.map(|pin| {
|
||||
div()
|
||||
.truncate()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(crate::util::pin_label(group, pin))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
items.push(
|
||||
DescriptionItem::new("Pinned").value(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(
|
||||
pins.iter()
|
||||
.map(|pin| {
|
||||
div()
|
||||
.truncate()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(crate::util::pin_label(group, pin))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !items.is_empty() {
|
||||
body = body.child(
|
||||
DescriptionList::vertical()
|
||||
.bordered(false)
|
||||
.columns(1)
|
||||
.children(items),
|
||||
);
|
||||
}
|
||||
|
||||
body.into_any_element()
|
||||
@@ -157,20 +174,6 @@ fn header(group: &Group, cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_medium()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(label),
|
||||
)
|
||||
.child(content)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_wrap()
|
||||
@@ -179,19 +182,6 @@ fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn chip(label: impl Into<SharedString>, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().secondary)
|
||||
.text_color(cx.theme().foreground)
|
||||
.text_xs()
|
||||
.line_height(relative(1.))
|
||||
.child(label.into())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn flags(metadata: &GroupMetadata) -> Vec<&'static str> {
|
||||
let mut flags = Vec::new();
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, ClipboardItem, Entity, IntoElement, ParentElement, SharedString, Styled,
|
||||
WeakEntity, Window, div, px,
|
||||
WeakEntity, Window, div, px, rems,
|
||||
};
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::input::InputState;
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
|
||||
@@ -24,7 +25,7 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
||||
});
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
this.max_h(px(600.))
|
||||
this.max_h(rems(37.5))
|
||||
.close_button(true)
|
||||
.title("Invites")
|
||||
.child(body(group.clone(), input.clone(), cx))
|
||||
@@ -126,19 +127,52 @@ fn copy_button(group: &WeakEntity<Group>, code: &str) -> Button {
|
||||
|
||||
fn revoke_button(group: &WeakEntity<Group>, id: nostr_sdk::prelude::EventId, code: &str) -> Button {
|
||||
let group = group.clone();
|
||||
let code = code.to_owned();
|
||||
|
||||
Button::new(SharedString::from(format!("invite-revoke-{code}")))
|
||||
.label("Revoke")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(move |_event, window, cx| {
|
||||
match group.update(cx, |group, cx| group.delete_event(id, cx)) {
|
||||
Ok(task) => report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: revoking an invite failed: {error}"),
|
||||
}
|
||||
confirm_revoke(group.clone(), id, code.clone(), window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
/// Confirm revoking an invite, then delete it and surface any relay error.
|
||||
fn confirm_revoke(
|
||||
group: WeakEntity<Group>,
|
||||
id: nostr_sdk::prelude::EventId,
|
||||
code: String,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
window.open_alert_dialog(cx, move |this, _window, _cx| {
|
||||
let group = group.clone();
|
||||
|
||||
this.confirm()
|
||||
.width(px(360.))
|
||||
.title(format!("Revoke invite {code}?"))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Revoke")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.child("Nobody will be able to ask to join with this code."),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| {
|
||||
match group.update(cx, |group, cx| group.delete_event(id, cx)) {
|
||||
Ok(task) => report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: revoking an invite failed: {error}"),
|
||||
}
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn create_row(group: WeakEntity<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement {
|
||||
h_flex()
|
||||
.items_end()
|
||||
|
||||
@@ -2,13 +2,13 @@ use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use chat_ui::ToggleDictation;
|
||||
use chat_ui::{Command, ToggleDictation};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
FollowMode, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list,
|
||||
px,
|
||||
Action, Anchor, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, FollowMode, InteractiveElement, IntoElement, ListAlignment,
|
||||
ListScrollEvent, ListState, ParentElement, Render, SharedString, Styled, Subscription, Task,
|
||||
WeakEntity, Window, div, list, px, rems,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
@@ -16,6 +16,7 @@ use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{InputEvent, Textarea, TextareaState};
|
||||
use gpui_kit::component::menu::DropdownMenu;
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::scroll::Scrollbar;
|
||||
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
|
||||
@@ -26,8 +27,9 @@ use person::Directory;
|
||||
use serde::Deserialize;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use speech::speaker::{END_GRACE, Speaker};
|
||||
use state::{NostrRegistry, pick_file, upload_file};
|
||||
use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text};
|
||||
use ui::{Avatar, REACTION_EMOJIS, RenderedText, RevealText, WelcomeMessage, rendered_text};
|
||||
use util::{attachment_name, display_name, opens_run, pin_label, report};
|
||||
|
||||
mod details;
|
||||
@@ -165,7 +167,7 @@ pub fn actions(group: WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
)
|
||||
.child(
|
||||
Button::new("group-delete")
|
||||
.icon(IconName::CircleX)
|
||||
.icon(IconName::Trash)
|
||||
.tooltip("Delete group")
|
||||
.ghost()
|
||||
.small()
|
||||
@@ -259,17 +261,43 @@ pub fn confirm_delete(group: WeakEntity<Group>, window: &mut Window, cx: &mut Ap
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove a member from the group and surface any relay error as a notification.
|
||||
pub fn remove_member(
|
||||
/// Confirm removing a member, then remove them and surface any relay error.
|
||||
pub fn confirm_remove_member(
|
||||
group: WeakEntity<Group>,
|
||||
public_key: PublicKey,
|
||||
member: SharedString,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
match group.update(cx, |group, cx| group.remove_user(public_key, cx)) {
|
||||
Ok(task) => report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: removing a group member failed: {error}"),
|
||||
}
|
||||
let Some(group_name) = group.read_with(cx, |group, _cx| group.display_name()).ok() else {
|
||||
return;
|
||||
};
|
||||
|
||||
window.open_alert_dialog(cx, move |this, _window, _cx| {
|
||||
let group = group.clone();
|
||||
|
||||
this.confirm()
|
||||
.width(px(360.))
|
||||
.title(format!("Remove {member} from {group_name}?"))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Remove")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.child("The relay will remove them from this group."),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| {
|
||||
match group.update(cx, |group, cx| group.remove_user(public_key, cx)) {
|
||||
Ok(task) => report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: removing a group member failed: {error}"),
|
||||
}
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Grant a member the admin role and surface any relay error as a notification.
|
||||
@@ -320,6 +348,12 @@ pub struct GroupPanel {
|
||||
speech: Entity<SpeechState>,
|
||||
/// When the running dictation started, for the elapsed counter
|
||||
speech_started: Option<instant::Instant>,
|
||||
/// Reads messages aloud
|
||||
speaker: Speaker,
|
||||
/// The message being revealed in step with speech, with its estimated duration
|
||||
speech_reveal: Option<(EventId, Duration)>,
|
||||
/// Fallback reveal end for backends that cannot report utterance end
|
||||
reveal_timeout: Option<Task<Result<()>>>,
|
||||
/// Uploaded, non-encrypted attachments waiting to be sent.
|
||||
attachments: Vec<Url>,
|
||||
/// Whether an upload is in flight.
|
||||
@@ -419,9 +453,24 @@ impl GroupPanel {
|
||||
}
|
||||
},
|
||||
));
|
||||
this.track_participants(cx);
|
||||
});
|
||||
|
||||
let mut this = Self {
|
||||
// End the speech reveal when the utterance finishes ahead of its estimate
|
||||
let speaker = Speaker::new();
|
||||
let end_events = speaker.end_events();
|
||||
let reveal_end_task = cx.spawn(async move |this, cx| {
|
||||
while end_events.recv_async().await.is_ok() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.speech_reveal = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
group: group.downgrade(),
|
||||
directory: Directory::new(cx),
|
||||
@@ -433,19 +482,17 @@ impl GroupPanel {
|
||||
input,
|
||||
speech,
|
||||
speech_started: None,
|
||||
speaker,
|
||||
speech_reveal: None,
|
||||
reveal_timeout: None,
|
||||
attachments: Vec::new(),
|
||||
uploading: false,
|
||||
tasks: smallvec![],
|
||||
tasks: smallvec![reveal_end_task],
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
this.track_participants(cx);
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/// Track the keys rendered by this panel: members, message authors, and
|
||||
/// activity subjects
|
||||
/// Track the keys rendered by this panel.
|
||||
fn track_participants(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(group) = self.group.upgrade() else {
|
||||
return;
|
||||
@@ -538,6 +585,39 @@ impl GroupPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a message aloud, tracking its reveal state until utterance end.
|
||||
fn speak(
|
||||
&mut self,
|
||||
id: EventId,
|
||||
text: SharedString,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match self.speaker.speak(&text) {
|
||||
Ok(duration) => {
|
||||
self.speech_reveal = Some((id, duration));
|
||||
|
||||
let session = (id, duration);
|
||||
let timer = cx.background_executor().timer(duration + END_GRACE);
|
||||
|
||||
self.reveal_timeout = Some(cx.spawn(async move |this, cx| {
|
||||
timer.await;
|
||||
this.update(cx, |this, cx| {
|
||||
if this.speech_reveal.as_ref() == Some(&session) {
|
||||
this.speech_reveal = None;
|
||||
cx.notify();
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) => window.push_notification(Notification::error(error.to_string()), cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_reply(&mut self, id: EventId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.reply_to = Some(id);
|
||||
self.input.update(cx, |input, cx| {
|
||||
@@ -695,18 +775,46 @@ impl GroupPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Ok(delete) = group.update(cx, |group, cx| group.delete_event(id, cx)) else {
|
||||
return;
|
||||
};
|
||||
window.open_alert_dialog(cx, move |this, _window, _cx| {
|
||||
let group = group.clone();
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
if let Err(error) = delete.await {
|
||||
this.update_in(cx, |_this, window, cx| {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
this.confirm()
|
||||
.width(px(360.))
|
||||
.title("Delete message?")
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Delete")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.child("The relay will delete this message for everyone."),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| {
|
||||
let Ok(delete) = group.update(cx, |group, cx| group.delete_event(id, cx))
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx| -> Result<()> {
|
||||
if let Err(error) = delete.await {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.detach();
|
||||
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn toggle_pin(
|
||||
@@ -857,16 +965,11 @@ impl GroupPanel {
|
||||
.py_1p5()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.children(pins.iter().map(|pin| {
|
||||
div()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().primary)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(pin_label(group, pin))
|
||||
}))
|
||||
.children(
|
||||
pins.iter().map(|pin| {
|
||||
gpui_kit::component::tag::Tag::secondary().child(pin_label(group, pin))
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -917,7 +1020,7 @@ impl GroupPanel {
|
||||
.child(
|
||||
Button::new("load-older")
|
||||
.label(if loading {
|
||||
"Loading earlier messages…"
|
||||
"Loading earlier messages"
|
||||
} else {
|
||||
"Load earlier messages"
|
||||
})
|
||||
@@ -975,7 +1078,7 @@ impl GroupPanel {
|
||||
row.child("You're in preview mode. Join this group to talk.")
|
||||
.child(
|
||||
Button::new("join")
|
||||
.label("Request to Join")
|
||||
.label("Request to join")
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
@@ -1023,22 +1126,33 @@ impl GroupPanel {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let directory = self.directory.clone();
|
||||
|
||||
let content = self
|
||||
.rendered_texts_by_id
|
||||
.entry(message.id)
|
||||
.or_insert_with(|| {
|
||||
rendered_text(
|
||||
&message.content,
|
||||
message
|
||||
.mentions
|
||||
.iter()
|
||||
.map(|mention| (mention.range.clone(), mention.public_key)),
|
||||
&message.emojis,
|
||||
&directory,
|
||||
let content = match self
|
||||
.speech_reveal
|
||||
.as_ref()
|
||||
.filter(|(reveal_id, _)| *reveal_id == message.id)
|
||||
{
|
||||
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
|
||||
None => self
|
||||
.rendered_texts_by_id
|
||||
.entry(message.id)
|
||||
.or_insert_with(|| {
|
||||
rendered_text(
|
||||
&message.content,
|
||||
message
|
||||
.mentions
|
||||
.iter()
|
||||
.map(|mention| (mention.range.clone(), mention.public_key)),
|
||||
&message.emojis,
|
||||
&directory,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.element(
|
||||
SharedString::from(format!("text-{}", message.id.to_hex())).into(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.element(ix.into(), window, cx);
|
||||
),
|
||||
};
|
||||
|
||||
let admin = nostr
|
||||
.read(cx)
|
||||
@@ -1059,6 +1173,7 @@ impl GroupPanel {
|
||||
panel: cx.weak_entity(),
|
||||
admin,
|
||||
pinned,
|
||||
speaking: self.speech_reveal.is_some(),
|
||||
persons: self.directory.clone(),
|
||||
},
|
||||
cx,
|
||||
@@ -1076,7 +1191,23 @@ impl GroupPanel {
|
||||
});
|
||||
}
|
||||
|
||||
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match command {
|
||||
Command::Insert(content) => {
|
||||
self.input.update(cx, |input, cx| {
|
||||
let new_value = format!("{} {}", input.value(), content);
|
||||
input.set_value(new_value, window, cx);
|
||||
});
|
||||
}
|
||||
Command::Copy(public_key) => match public_key.to_bech32() {
|
||||
Ok(content) => cx.write_to_clipboard(ClipboardItem::new_string(content)),
|
||||
Err(error) => log::warn!("nip29: encoding the author key failed: {error}"),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_composer(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let notice = self.group.upgrade().and_then(|group| {
|
||||
let group = group.read(cx);
|
||||
|
||||
@@ -1107,18 +1238,17 @@ impl GroupPanel {
|
||||
.flex_shrink_0()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.gap_1()
|
||||
.gap_1p5()
|
||||
.when_some(self.reply_to, |this, target| {
|
||||
this.child(self.render_reply_target(target, cx))
|
||||
})
|
||||
.children(self.render_attachments(cx))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_end()
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(ui::IconName::Paperclip)
|
||||
.icon(IconName::Paperclip)
|
||||
.tooltip("Upload media")
|
||||
.loading(self.uploading)
|
||||
.disabled(dictating || self.uploading)
|
||||
@@ -1133,6 +1263,7 @@ impl GroupPanel {
|
||||
.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))
|
||||
@@ -1143,28 +1274,69 @@ impl GroupPanel {
|
||||
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(
|
||||
Button::new("send")
|
||||
.icon(ui::IconName::PaperPlaneFill)
|
||||
.tooltip("Send")
|
||||
.ghost()
|
||||
.disabled(dictating || self.uploading)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.send(window, cx);
|
||||
})),
|
||||
h_flex()
|
||||
.pl_1()
|
||||
.gap_1()
|
||||
.child(SpeechButton::new(&self.speech))
|
||||
.child(self.render_emoji_menu(dictating, window, cx))
|
||||
.child(
|
||||
Button::new("send")
|
||||
.icon(IconName::SendHorizontal)
|
||||
.tooltip("Send")
|
||||
.primary()
|
||||
.disabled(dictating || self.uploading)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.send(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_emoji_menu(
|
||||
&self,
|
||||
disabled: bool,
|
||||
_window: &Window,
|
||||
cx: &Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let current_user = nostr.read(cx).current_user();
|
||||
|
||||
let emojis = current_user
|
||||
.map(|current_user| {
|
||||
self.directory
|
||||
.read(cx)
|
||||
.person(¤t_user, cx)
|
||||
.emojis()
|
||||
.to_vec()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Button::new("emoji")
|
||||
.icon(IconName::FaceGrinning)
|
||||
.tooltip("Emoji")
|
||||
.ghost()
|
||||
.disabled(disabled)
|
||||
.dropdown_menu_with_anchor(Anchor::BottomLeft, move |this, _window, _cx| {
|
||||
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
|
||||
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
|
||||
});
|
||||
|
||||
emojis.iter().fold(menu, |this, (shortcode, _)| {
|
||||
let label = format!(":{shortcode}:");
|
||||
this.menu(label.clone(), Box::new(Command::Insert(label)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn render_reply_target(&self, target: EventId, cx: &mut Context<Self>) -> AnyElement {
|
||||
let context = self.group.upgrade().and_then(|entity| {
|
||||
entity.read_with(cx, |group, cx| {
|
||||
@@ -1205,7 +1377,7 @@ impl GroupPanel {
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.max_w(px(140.))
|
||||
.max_w(rems(8.75))
|
||||
.truncate()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().foreground)
|
||||
@@ -1221,7 +1393,7 @@ impl GroupPanel {
|
||||
)
|
||||
.child(
|
||||
Button::new("cancel-reply")
|
||||
.icon(ui::IconName::Close)
|
||||
.icon(IconName::Close)
|
||||
.tooltip("Cancel reply")
|
||||
.ghost()
|
||||
.small()
|
||||
@@ -1235,8 +1407,7 @@ impl GroupPanel {
|
||||
fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
||||
self.attachments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, url)| {
|
||||
.map(|url| {
|
||||
let url = url.clone();
|
||||
|
||||
h_flex()
|
||||
@@ -1252,13 +1423,13 @@ impl GroupPanel {
|
||||
.child(Icon::new(IconName::Link).xsmall())
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(200.))
|
||||
.max_w(rems(12.5))
|
||||
.truncate()
|
||||
.child(attachment_name(&url)),
|
||||
)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("remove-attachment-{ix}")))
|
||||
.icon(ui::IconName::Close)
|
||||
Button::new(SharedString::from(format!("remove-attachment-{}", url)))
|
||||
.icon(IconName::Close)
|
||||
.tooltip("Remove")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
@@ -1347,13 +1518,14 @@ impl Focusable for GroupPanel {
|
||||
}
|
||||
|
||||
impl Render for GroupPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let notice = self.notice(cx);
|
||||
let empty = self.item_count(cx) == 2;
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.min_w_0()
|
||||
.on_action(cx.listener(Self::on_command))
|
||||
.on_action(cx.listener(Self::toggle_dictation))
|
||||
.child(self.render_encryption_notice(cx))
|
||||
.child(self.render_banner(cx))
|
||||
@@ -1394,6 +1566,6 @@ impl Render for GroupPanel {
|
||||
})
|
||||
.child(Scrollbar::vertical(&self.list_state)),
|
||||
)
|
||||
.child(self.render_composer(cx))
|
||||
.child(self.render_composer(window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
use chat::Message;
|
||||
use chat_ui::Command;
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString, Styled,
|
||||
WeakEntity, div,
|
||||
AnyElement, App, ClipboardItem, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
SharedString, Styled, WeakEntity, div,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
|
||||
use nip29::{Activity, Group, Reaction};
|
||||
use nostr_sdk::prelude::{EventId, Kind};
|
||||
use nostr_sdk::prelude::{EventId, Kind, ToBech32};
|
||||
use person::Directory;
|
||||
use settings::AppSettings;
|
||||
use state::NostrRegistry;
|
||||
use ui::{
|
||||
Avatar, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview, group_reactions,
|
||||
react_menu,
|
||||
Avatar, COMPACT_REACTION_EMOJIS, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS,
|
||||
ReactionBar, ReplyPreview, group_reactions, react_menu,
|
||||
};
|
||||
|
||||
use crate::GroupPanel;
|
||||
@@ -26,11 +27,13 @@ pub(crate) struct Actions {
|
||||
pub panel: WeakEntity<GroupPanel>,
|
||||
pub admin: bool,
|
||||
pub pinned: bool,
|
||||
/// Whether a message is currently being spoken, disabling the speech buttons
|
||||
pub speaking: bool,
|
||||
pub persons: Entity<Directory>,
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
ix: usize,
|
||||
_ix: usize,
|
||||
message: &Message,
|
||||
content: AnyElement,
|
||||
show_author: bool,
|
||||
@@ -55,42 +58,94 @@ pub(crate) fn render(
|
||||
nostr.read(cx).current_user(),
|
||||
);
|
||||
|
||||
let row = MessageRow::new(ix)
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.author(author.name())
|
||||
.timestamp(message.created_at.to_human_time())
|
||||
.when(!message.replies_to.is_empty(), |this| {
|
||||
this.children(replies(
|
||||
&message.replies_to,
|
||||
&persons,
|
||||
&actions.group,
|
||||
&actions.panel,
|
||||
cx,
|
||||
))
|
||||
})
|
||||
.child(content)
|
||||
.when(!message.media.is_empty(), |this| {
|
||||
this.child(MessageMedia::new(&message.media))
|
||||
})
|
||||
.when(!reaction_groups.is_empty(), |this| {
|
||||
this.child(ReactionBar::new(reaction_groups))
|
||||
});
|
||||
let row = MessageRow::new(SharedString::from(format!(
|
||||
"message-{}",
|
||||
message.id.to_hex()
|
||||
)))
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.author(author.name())
|
||||
.timestamp(message.created_at.to_human_time())
|
||||
.when(!message.replies_to.is_empty(), |this| {
|
||||
this.children(replies(
|
||||
&message.replies_to,
|
||||
&persons,
|
||||
&actions.group,
|
||||
&actions.panel,
|
||||
cx,
|
||||
))
|
||||
})
|
||||
.child(content)
|
||||
.when(!message.media.is_empty(), |this| {
|
||||
this.child(MessageMedia::new(&message.media))
|
||||
})
|
||||
.when(!reaction_groups.is_empty(), |this| {
|
||||
this.child(ReactionBar::new(reaction_groups))
|
||||
});
|
||||
|
||||
let id = message.id;
|
||||
let admin = actions.admin;
|
||||
let pinned = actions.pinned;
|
||||
let group = actions.group;
|
||||
let panel = actions.panel;
|
||||
let speech_panel = panel.clone();
|
||||
let speech_text = message.preview();
|
||||
let speech_id = message.id;
|
||||
let speaking = actions.speaking;
|
||||
let author = message.author;
|
||||
|
||||
let mut bar = MessageActions::new();
|
||||
|
||||
for emoji in COMPACT_REACTION_EMOJIS {
|
||||
let emoji = *emoji;
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
bar = bar.reaction(emoji, 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 reply_panel = panel.clone();
|
||||
let bar = bar.reply(move |_event, _window, cx| {
|
||||
if let Err(error) = reply_panel.update_in(cx, |panel, window, cx| {
|
||||
panel.start_reply(id, window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: starting a reply failed: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
let bar = bar
|
||||
.copy(
|
||||
SharedString::from(format!("message-copy-{id}")),
|
||||
message.preview(),
|
||||
)
|
||||
.speech(speaking, {
|
||||
let speech_text = speech_text.clone();
|
||||
|
||||
move |_event, _window, cx| {
|
||||
if let Err(error) = speech_panel.update_in(cx, |panel, window, cx| {
|
||||
panel.speak(speech_id, speech_text.clone(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: reading the message aloud failed: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let row = row.overlay(bar.dropdown(move |menu, _window, _cx| {
|
||||
menu.menu("Copy author", Box::new(Command::Copy(author)))
|
||||
}));
|
||||
|
||||
// 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()
|
||||
@@ -115,9 +170,11 @@ pub(crate) fn render(
|
||||
let panel = panel.clone();
|
||||
|
||||
PopupMenuItem::new("Reply").on_click(move |_event, _window, cx| {
|
||||
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.start_reply(id, window, cx);
|
||||
});
|
||||
}) {
|
||||
log::warn!("nip29: starting a reply failed: {error}");
|
||||
}
|
||||
})
|
||||
})
|
||||
.submenu("React", window, cx, {
|
||||
@@ -137,6 +194,29 @@ pub(crate) fn render(
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.item({
|
||||
let panel = panel.clone();
|
||||
let speech_text = speech_text.clone();
|
||||
let speech_id = id;
|
||||
|
||||
PopupMenuItem::new("Read aloud")
|
||||
.disabled(speaking)
|
||||
.on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.speak(speech_id, speech_text.clone(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: reading the message aloud failed: {error}");
|
||||
}
|
||||
})
|
||||
})
|
||||
.item({
|
||||
let author = author;
|
||||
|
||||
PopupMenuItem::new("Copy author").on_click(move |_event, _window, cx| {
|
||||
let npub = author.to_bech32().unwrap_or_default();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(npub));
|
||||
})
|
||||
});
|
||||
|
||||
if !admin {
|
||||
@@ -172,7 +252,7 @@ pub(crate) fn render(
|
||||
}
|
||||
|
||||
pub(crate) fn render_activity(
|
||||
ix: usize,
|
||||
_ix: usize,
|
||||
activity: &Activity,
|
||||
persons: &Entity<Directory>,
|
||||
cx: &App,
|
||||
@@ -181,12 +261,14 @@ pub(crate) fn render_activity(
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.id(SharedString::from(format!(
|
||||
"activity-{}",
|
||||
activity.id.to_hex()
|
||||
)))
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.child(badge(activity, cx))
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
@@ -263,7 +345,7 @@ fn replies(
|
||||
|
||||
let mut items = Vec::with_capacity(targets.len());
|
||||
|
||||
for (ix, id) in targets.iter().enumerate() {
|
||||
for id in targets.iter() {
|
||||
let context = entity.read_with(cx, |group, cx| {
|
||||
let message = group.message(*id)?;
|
||||
let author = persons.read(cx).person(&message.author, cx);
|
||||
@@ -282,15 +364,19 @@ fn replies(
|
||||
let panel = panel.clone();
|
||||
|
||||
items.push(
|
||||
ReplyPreview::new(ix, author, preview)
|
||||
.on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update(cx, |panel, cx| {
|
||||
panel.scroll_to(&id, cx);
|
||||
}) {
|
||||
log::warn!("nip29: scrolling to message failed: {error}");
|
||||
}
|
||||
})
|
||||
.into_any_element(),
|
||||
ReplyPreview::new(
|
||||
SharedString::from(format!("reply-{}", id.to_hex())),
|
||||
author,
|
||||
preview,
|
||||
)
|
||||
.on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update(cx, |panel, cx| {
|
||||
panel.scroll_to(&id, cx);
|
||||
}) {
|
||||
log::warn!("nip29: scrolling to message failed: {error}");
|
||||
}
|
||||
})
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::input::InputState;
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::switch::Switch;
|
||||
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||
use nip29::{Group, GroupId, GroupMetadata};
|
||||
use nostr_sdk::prelude::RelayUrl;
|
||||
use ui::settings_row;
|
||||
@@ -100,7 +100,7 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
||||
let supported = *livekit.read(cx) != Some(false);
|
||||
|
||||
this.close_button(true)
|
||||
.title("Edit Group")
|
||||
.title("Edit group")
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
@@ -161,24 +161,27 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
||||
.child(settings_row(
|
||||
"LiveKit",
|
||||
if supported {
|
||||
"Announce audio and video support."
|
||||
"Announce audio and video support"
|
||||
} else {
|
||||
"The relay doesn't announce LiveKit support."
|
||||
},
|
||||
Switch::new("livekit").checked(current.livekit).on_click({
|
||||
let flags = flags.clone();
|
||||
let livekit = livekit.clone();
|
||||
Switch::new("livekit")
|
||||
.checked(current.livekit)
|
||||
.disabled(!supported)
|
||||
.on_click({
|
||||
let flags = flags.clone();
|
||||
let livekit = livekit.clone();
|
||||
|
||||
move |next, _window, cx| {
|
||||
if *next && *livekit.read(cx) == Some(false) {
|
||||
return;
|
||||
move |next, _window, cx| {
|
||||
if *next && *livekit.read(cx) == Some(false) {
|
||||
return;
|
||||
}
|
||||
flags.update(cx, |flags, cx| {
|
||||
flags.livekit = *next;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
flags.update(cx, |flags, cx| {
|
||||
flags.livekit = *next;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
@@ -293,10 +296,10 @@ fn children_section(children: &Entity<Vec<GroupId>>, cx: &App) -> AnyElement {
|
||||
.flex_shrink_0()
|
||||
.gap_1()
|
||||
.when(ix > 0, |this| {
|
||||
this.child(move_button(children.clone(), ix, ix - 1, "up"))
|
||||
this.child(move_button(children.clone(), child, ix, ix - 1, "up"))
|
||||
})
|
||||
.when(ix + 1 < list.len(), |this| {
|
||||
this.child(move_button(children.clone(), ix, ix + 1, "down"))
|
||||
this.child(move_button(children.clone(), child, ix, ix + 1, "down"))
|
||||
});
|
||||
|
||||
section = section.child(
|
||||
@@ -319,11 +322,12 @@ fn children_section(children: &Entity<Vec<GroupId>>, cx: &App) -> AnyElement {
|
||||
|
||||
fn move_button(
|
||||
children: Entity<Vec<GroupId>>,
|
||||
child: &GroupId,
|
||||
ix: usize,
|
||||
target: usize,
|
||||
direction: &'static str,
|
||||
) -> Button {
|
||||
Button::new(SharedString::from(format!("child-{direction}-{ix}")))
|
||||
Button::new(SharedString::from(format!("child-{direction}-{child}")))
|
||||
.label(if direction == "up" { "Up" } else { "Down" })
|
||||
.ghost()
|
||||
.small()
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
use anyhow::{Error, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// The storage identifier for the device encryption key
|
||||
const IDENTIFIER: &str = "coop:device";
|
||||
/// The storage identifier for encryption keys retired by a rotation
|
||||
const ARCHIVE_IDENTIFIER: &str = "coop:device-archive";
|
||||
/// Keep the archived key list bounded
|
||||
const ARCHIVE_LIMIT: usize = 10;
|
||||
/// Seconds in a day, for rotation intervals
|
||||
pub(crate) const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
|
||||
|
||||
/// Device encryption key material with its creation time
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DeviceKeys {
|
||||
pub(crate) keys: Keys,
|
||||
pub(crate) created_at: Timestamp,
|
||||
}
|
||||
|
||||
/// Encode the stored key content: secret hex and creation time
|
||||
fn encode_keys_content(secret: &str, created_at: &Timestamp) -> String {
|
||||
format!("{secret}\n{}", created_at.as_secs())
|
||||
}
|
||||
|
||||
/// Parse stored key content.
|
||||
fn parse_keys_content(content: &str) -> Result<(SecretKey, Timestamp), Error> {
|
||||
match content.split_once('\n') {
|
||||
Some((secret, created_at)) => {
|
||||
let created_at = created_at
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.unwrap_or_else(|_| Timestamp::now().as_secs());
|
||||
Ok((
|
||||
SecretKey::from_hex(secret)?,
|
||||
Timestamp::from_secs(created_at),
|
||||
))
|
||||
}
|
||||
None => Ok((SecretKey::from_hex(content)?, Timestamp::now())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse archived secrets from stored content, one hex secret per line
|
||||
fn parse_archived_keys(content: &str) -> Vec<Keys> {
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| SecretKey::from_hex(line.trim()).ok())
|
||||
.map(Keys::new)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Load the device keys with their creation time from the local database.
|
||||
pub(crate) async fn load_device_keys<S>(client: &Client, signer: &S) -> Result<DeviceKeys, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(IDENTIFIER)
|
||||
.author(public_key);
|
||||
|
||||
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
|
||||
return Err(anyhow!("Key not found"));
|
||||
};
|
||||
|
||||
let content = signer
|
||||
.nip44_decrypt_async(&public_key, &event.content)
|
||||
.await?;
|
||||
let (secret, created_at) = parse_keys_content(&content)?;
|
||||
|
||||
Ok(DeviceKeys {
|
||||
keys: Keys::new(secret),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encrypt and store device keys in the local database.
|
||||
pub(crate) async fn set_keys<S>(
|
||||
client: &Client,
|
||||
signer: &S,
|
||||
secret: &str,
|
||||
created_at: Timestamp,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let content = signer
|
||||
.nip44_encrypt_async(&public_key, &encode_keys_content(secret, &created_at))
|
||||
.await?;
|
||||
|
||||
// Construct the application data event
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tag(Tag::identifier(IDENTIFIER))
|
||||
.finalize_async(signer)
|
||||
.await?;
|
||||
|
||||
// Save the event to the database
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append an encryption secret to the bounded archive of retired keys.
|
||||
pub(crate) async fn archive_key<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(ARCHIVE_IDENTIFIER)
|
||||
.author(public_key);
|
||||
|
||||
let mut archived: Vec<String> = match client.database().query(filter).await?.into_iter().next()
|
||||
{
|
||||
Some(event) => {
|
||||
let content = signer
|
||||
.nip44_decrypt_async(&public_key, &event.content)
|
||||
.await?;
|
||||
content.lines().map(str::to_string).collect()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
archived.insert(0, secret.to_string());
|
||||
archived.truncate(ARCHIVE_LIMIT);
|
||||
|
||||
let content = signer
|
||||
.nip44_encrypt_async(&public_key, &archived.join("\n"))
|
||||
.await?;
|
||||
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tag(Tag::identifier(ARCHIVE_IDENTIFIER))
|
||||
.finalize_async(signer)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get device keys from the local database.
|
||||
pub(crate) async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
Ok(load_device_keys(client, signer).await?.keys)
|
||||
}
|
||||
|
||||
/// Load encryption keys retired by a rotation from the local database.
|
||||
pub(crate) async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(ARCHIVE_IDENTIFIER)
|
||||
.author(public_key);
|
||||
|
||||
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
let content = signer
|
||||
.nip44_decrypt_async(&public_key, &event.content)
|
||||
.await?;
|
||||
|
||||
Ok(parse_archived_keys(&content))
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "nip60"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
state = { path = "../state" }
|
||||
common = { path = "../common" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
flume.workspace = true
|
||||
serde_json.workspace = true
|
||||
futures.workspace = true
|
||||
hkdf.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio = { version = "1", default-features = false, features = [ "rt" ] }
|
||||
|
||||
cdk.workspace = true
|
||||
cdk-redb.workspace = true
|
||||
@@ -0,0 +1,369 @@
|
||||
use anyhow::{Error, anyhow};
|
||||
use hkdf::Hkdf;
|
||||
use nostr::nips::nip60::TokenEvent;
|
||||
use nostr_sdk::prelude::*;
|
||||
use sha2::Sha256;
|
||||
|
||||
const SEED_SALT: &[u8] = b"coop nip60 seed salt";
|
||||
const SEED_INFO: &[u8] = b"coop cashu seed";
|
||||
|
||||
/// Parsed 17375 wallet event: the wallet key and its mint list.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct WalletData {
|
||||
pub(crate) event_id: EventId,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) privkey: SecretKey,
|
||||
pub(crate) mints: Vec<Url>,
|
||||
}
|
||||
|
||||
impl WalletData {
|
||||
/// A new wallet with a fresh key.
|
||||
pub(crate) fn generate(first_mint: Url) -> Self {
|
||||
Self {
|
||||
event_id: EventId::from_byte_array([0; 32]),
|
||||
created_at: Timestamp::from_secs(0),
|
||||
privkey: SecretKey::generate(),
|
||||
mints: vec![first_mint],
|
||||
}
|
||||
}
|
||||
|
||||
/// The cdk seed, derived deterministically from the wallet key.
|
||||
pub(crate) fn derive_seed(privkey: &SecretKey) -> Result<[u8; 64], Error> {
|
||||
let ikm = privkey.to_secret_bytes();
|
||||
let hk = Hkdf::<Sha256>::new(Some(SEED_SALT), &ikm);
|
||||
let mut seed = [0u8; 64];
|
||||
hk.expand(SEED_INFO, &mut seed)
|
||||
.map_err(|error| anyhow!("seed derivation failed: {error}"))?;
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
/// Decrypt and parse a 17375 event published by this account.
|
||||
pub(crate) async fn decrypt<S>(signer: &S, me: &PublicKey, event: &Event) -> Result<Self, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
if event.pubkey != *me {
|
||||
return Err(anyhow!("wallet event was not published by this account"));
|
||||
}
|
||||
|
||||
let content = signer.nip44_decrypt_async(me, &event.content).await?;
|
||||
let (privkey, mints) = parse_wallet_content(&content)?;
|
||||
|
||||
Ok(Self {
|
||||
event_id: event.id,
|
||||
created_at: event.created_at,
|
||||
privkey,
|
||||
mints,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the encrypted 17375 event for this wallet data.
|
||||
pub(crate) async fn to_event<S>(&self, signer: &S, me: &PublicKey) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let mut rows = vec![vec!["privkey".to_string(), self.privkey.to_secret_hex()]];
|
||||
for mint in &self.mints {
|
||||
rows.push(vec!["mint".to_string(), mint.to_string()]);
|
||||
}
|
||||
|
||||
let content = signer
|
||||
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
|
||||
.await?;
|
||||
|
||||
Ok(EventBuilder::new(Kind::CashuWallet, content)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec<Url>), Error> {
|
||||
let rows: Vec<Vec<String>> =
|
||||
serde_json::from_str(content).map_err(|error| anyhow!("invalid wallet event: {error}"))?;
|
||||
|
||||
let mut privkey = None;
|
||||
let mut mints = Vec::new();
|
||||
for row in rows {
|
||||
let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
|
||||
continue;
|
||||
};
|
||||
match key.as_str() {
|
||||
"privkey" if privkey.is_none() => privkey = Some(SecretKey::from_hex(value)?),
|
||||
"mint" => {
|
||||
if let Ok(mint) = Url::parse(value) {
|
||||
mints.push(mint);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(privkey) = privkey else {
|
||||
return Err(anyhow!("wallet event is missing the private key"));
|
||||
};
|
||||
if mints.is_empty() {
|
||||
return Err(anyhow!("wallet event is missing mints"));
|
||||
}
|
||||
|
||||
Ok((privkey, mints))
|
||||
}
|
||||
|
||||
pub(crate) async fn decrypt_token_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
event: &Event,
|
||||
) -> Result<TokenEvent, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
if event.pubkey != *me {
|
||||
return Err(anyhow!("token event was not published by this account"));
|
||||
}
|
||||
|
||||
let content = signer.nip44_decrypt_async(me, &event.content).await?;
|
||||
|
||||
let token =
|
||||
serde_json::from_str(&content).map_err(|error| anyhow!("invalid token event: {error}"))?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_token_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
token: &TokenEvent,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let content = signer
|
||||
.nip44_encrypt_async(me, &serde_json::to_string(token)?)
|
||||
.await?;
|
||||
|
||||
Ok(EventBuilder::new(Kind::CashuWalletUnspentProof, content)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// NIP-09 deletion carrying the `k: 7375` tag required by NIP-60.
|
||||
pub(crate) async fn build_token_deletion<S>(signer: &S, ids: &[EventId]) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let mut tags: Vec<Tag> = ids.iter().map(|id| Tag::event(*id)).collect();
|
||||
tags.push(Tag::custom("k", ["7375".to_string()]));
|
||||
|
||||
Ok(EventBuilder::new(Kind::EventDeletion, "")
|
||||
.tags(tags)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Decrypted 7376 payload, enough for the wallet panel's history list.
|
||||
pub(crate) struct HistoryPayload {
|
||||
pub(crate) direction: TransactionDirection,
|
||||
pub(crate) amount: u64,
|
||||
}
|
||||
|
||||
pub(crate) async fn decrypt_history_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
event: &Event,
|
||||
) -> Result<HistoryPayload, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
if event.pubkey != *me {
|
||||
return Err(anyhow!("history event was not published by this account"));
|
||||
}
|
||||
|
||||
let content = signer.nip44_decrypt_async(me, &event.content).await?;
|
||||
let rows: Vec<Vec<String>> = serde_json::from_str(&content)
|
||||
.map_err(|error| anyhow!("invalid history event: {error}"))?;
|
||||
|
||||
let mut direction = None;
|
||||
let mut amount = None;
|
||||
for row in rows {
|
||||
let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
|
||||
continue;
|
||||
};
|
||||
match key.as_str() {
|
||||
"direction" if direction.is_none() => direction = value.parse().ok(),
|
||||
"amount" if amount.is_none() => amount = value.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(direction) = direction else {
|
||||
return Err(anyhow!("history event is missing the direction"));
|
||||
};
|
||||
let Some(amount) = amount else {
|
||||
return Err(anyhow!("history event is missing the amount"));
|
||||
};
|
||||
|
||||
Ok(HistoryPayload { direction, amount })
|
||||
}
|
||||
|
||||
/// Build the public kind 9321 nutzap event carrying P2PK-locked proofs.
|
||||
pub(crate) async fn build_nutzap_event<S>(
|
||||
signer: &S,
|
||||
recipient: PublicKey,
|
||||
mint: &str,
|
||||
proofs: &[String],
|
||||
target: Option<EventId>,
|
||||
comment: &str,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let mut tags: Vec<Tag> = vec![
|
||||
Tag::custom("p", [recipient.to_hex()]),
|
||||
Tag::custom("u", [mint.to_string()]),
|
||||
Tag::custom("unit", ["sat".to_string()]),
|
||||
];
|
||||
|
||||
for proof in proofs {
|
||||
tags.push(Tag::custom("proof", [proof.clone()]));
|
||||
}
|
||||
|
||||
if let Some(target) = target {
|
||||
tags.push(Tag::custom("e", [target.to_hex()]));
|
||||
}
|
||||
|
||||
Ok(EventBuilder::new(Kind::Custom(9321), comment)
|
||||
.tags(tags)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Encrypted 7376 history payload for one state change.
|
||||
pub(crate) struct History {
|
||||
pub(crate) direction: TransactionDirection,
|
||||
pub(crate) amount: u64,
|
||||
/// NIP-61 nutzap events redeemed by this state change
|
||||
pub(crate) redeemed: Vec<EventId>,
|
||||
/// Author of the redeemed nutzap, for the unencrypted `p` tag
|
||||
pub(crate) sender: Option<PublicKey>,
|
||||
}
|
||||
|
||||
pub(crate) async fn build_history_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
history: &History,
|
||||
created: EventId,
|
||||
destroyed: &[EventId],
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let mut rows = vec![
|
||||
vec!["direction".to_string(), history.direction.to_string()],
|
||||
vec!["amount".to_string(), history.amount.to_string()],
|
||||
];
|
||||
|
||||
for id in destroyed {
|
||||
rows.push(vec![
|
||||
"e".to_string(),
|
||||
id.to_hex(),
|
||||
String::new(),
|
||||
"destroyed".to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
rows.push(vec![
|
||||
"e".to_string(),
|
||||
created.to_hex(),
|
||||
String::new(),
|
||||
"created".to_string(),
|
||||
]);
|
||||
|
||||
let mut tags: Vec<Tag> = history
|
||||
.redeemed
|
||||
.iter()
|
||||
.map(|id| Tag::custom("e", [id.to_hex(), String::new(), "redeemed".to_string()]))
|
||||
.collect();
|
||||
|
||||
if let Some(sender) = history.sender {
|
||||
tags.push(Tag::custom("p", [sender.to_hex()]));
|
||||
}
|
||||
|
||||
let content = signer
|
||||
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
|
||||
.await?;
|
||||
|
||||
Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content)
|
||||
.tags(tags)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_nutzap_info_event<S>(
|
||||
signer: &S,
|
||||
mints: &[Url],
|
||||
relays: &[RelayUrl],
|
||||
wallet_pubkey: PublicKey,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let mut tags: Vec<Tag> = relays
|
||||
.iter()
|
||||
.map(|relay| Tag::custom("relay", [relay.to_string()]))
|
||||
.collect();
|
||||
|
||||
for mint in mints {
|
||||
tags.push(Tag::custom("mint", [mint.to_string(), "sat".to_string()]));
|
||||
}
|
||||
|
||||
tags.push(Tag::custom(
|
||||
"pubkey",
|
||||
[format!("02{}", wallet_pubkey.to_hex())],
|
||||
));
|
||||
|
||||
Ok(EventBuilder::new(Kind::Custom(10019), "")
|
||||
.tags(tags)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_wallet_content() {
|
||||
let privkey = SecretKey::generate();
|
||||
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
|
||||
|
||||
let content = serde_json::to_string(&vec![
|
||||
vec!["privkey".to_string(), privkey.to_secret_hex()],
|
||||
vec!["mint".to_string(), mint.to_string()],
|
||||
])
|
||||
.expect("serialization cannot fail");
|
||||
|
||||
let (parsed_key, mints) = parse_wallet_content(&content).expect("valid content");
|
||||
assert_eq!(parsed_key.to_secret_hex(), privkey.to_secret_hex());
|
||||
assert_eq!(mints, vec![mint]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_content_rejects_missing_privkey() {
|
||||
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
|
||||
let content = serde_json::to_string(&vec![vec!["mint".to_string(), mint.to_string()]])
|
||||
.expect("serialization cannot fail");
|
||||
|
||||
assert!(parse_wallet_content(&content).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_is_deterministic_per_key() {
|
||||
let key = SecretKey::generate();
|
||||
let first = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
|
||||
let second = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
|
||||
assert_eq!(first, second);
|
||||
|
||||
let other =
|
||||
WalletData::derive_seed(&SecretKey::generate()).expect("seed derivation cannot fail");
|
||||
assert_ne!(first, other);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use cdk::Amount;
|
||||
use cdk::amount::SplitTarget;
|
||||
use cdk::mint_url::MintUrl;
|
||||
use cdk::nuts::nut00::KnownMethod;
|
||||
use cdk::nuts::{
|
||||
CurrencyUnit, Id, KeySetInfo, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs,
|
||||
SecretKey, SpendingConditions, State,
|
||||
};
|
||||
use cdk::secret::Secret;
|
||||
use cdk::wallet::types::ProofInfo;
|
||||
use cdk::wallet::{ReceiveOptions, SendOptions, Wallet};
|
||||
use futures::StreamExt;
|
||||
use nostr::nips::nip60::{CashuProof, TokenEvent, TransactionDirection};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::keys;
|
||||
|
||||
/// How long to wait for relays when fetching a recipient's wallet info
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// A job mutating the wallet store or reconciling it with relays.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Job {
|
||||
Reconcile,
|
||||
CreateQuote {
|
||||
mint: MintUrl,
|
||||
amount: u64,
|
||||
},
|
||||
ClaimQuote {
|
||||
mint: MintUrl,
|
||||
quote_id: String,
|
||||
},
|
||||
Melt {
|
||||
mint: MintUrl,
|
||||
invoice: String,
|
||||
},
|
||||
SendToken {
|
||||
mint: MintUrl,
|
||||
amount: u64,
|
||||
},
|
||||
SendNutzap {
|
||||
recipient: PublicKey,
|
||||
amount: u64,
|
||||
target: Option<EventId>,
|
||||
comment: Option<String>,
|
||||
},
|
||||
Receive {
|
||||
mint: MintUrl,
|
||||
token: String,
|
||||
},
|
||||
RedeemNutzap {
|
||||
mint: MintUrl,
|
||||
proofs: Proofs,
|
||||
nutzap_id: EventId,
|
||||
sender: PublicKey,
|
||||
},
|
||||
}
|
||||
|
||||
impl Job {
|
||||
pub(crate) async fn run<S>(self, ctx: JobCtx<S>) -> Result<Outcome, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
|
||||
{
|
||||
match self {
|
||||
Job::Reconcile => ctx.reconcile().await,
|
||||
Job::CreateQuote { mint, amount } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let quote = wallet
|
||||
.mint_quote(
|
||||
PaymentMethod::Known(KnownMethod::Bolt11),
|
||||
Some(Amount::from(amount)),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::QuoteCreated {
|
||||
mint,
|
||||
quote_id: quote.id,
|
||||
request: quote.request,
|
||||
amount,
|
||||
})
|
||||
}
|
||||
Job::ClaimQuote { mint, quote_id } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let quote = wallet.check_mint_quote("e_id).await?;
|
||||
|
||||
if quote.state != MintQuoteState::Paid {
|
||||
return Ok(Outcome::Idle);
|
||||
}
|
||||
|
||||
let proofs = wallet.mint("e_id, SplitTarget::default(), None).await?;
|
||||
let amount: u64 = proofs.iter().map(|proof| u64::from(proof.amount)).sum();
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::In,
|
||||
amount: u64::from(quote.amount.unwrap_or_default()),
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::QuoteMinted {
|
||||
quote_id,
|
||||
update,
|
||||
amount,
|
||||
})
|
||||
}
|
||||
Job::Melt { mint, invoice } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let quote = wallet
|
||||
.melt_quote(
|
||||
PaymentMethod::Known(KnownMethod::Bolt11),
|
||||
invoice,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let finalized = wallet
|
||||
.prepare_melt("e.id, HashMap::new())
|
||||
.await?
|
||||
.confirm()
|
||||
.await?;
|
||||
|
||||
if finalized.state() != MeltQuoteState::Paid {
|
||||
return Err(anyhow!("melt did not settle: {:?}", finalized.state()));
|
||||
}
|
||||
|
||||
let amount = u64::from(finalized.total_amount());
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::Out,
|
||||
amount,
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Melted { update, amount })
|
||||
}
|
||||
Job::SendToken { mint, amount } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let prepared = wallet
|
||||
.prepare_send(Amount::from(amount), SendOptions::default())
|
||||
.await?;
|
||||
let token = prepared.confirm(None).await?.to_string();
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::Out,
|
||||
amount,
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Sent { update, token })
|
||||
}
|
||||
Job::SendNutzap {
|
||||
recipient,
|
||||
amount,
|
||||
target,
|
||||
comment,
|
||||
} => {
|
||||
let (mint_url, mint_tag, p2pk) = recipient_wallet_info(&ctx, recipient).await?;
|
||||
let wallet = ctx.wallet(&mint_url)?;
|
||||
|
||||
let keysets = wallet
|
||||
.keysets(cdk::wallet::types::KeysetLoadPolicy::CacheOnly)
|
||||
.await?;
|
||||
|
||||
let infos: Vec<KeySetInfo> = keysets
|
||||
.iter()
|
||||
.map(|keyset| KeySetInfo {
|
||||
id: keyset.id,
|
||||
unit: keyset.unit.clone(),
|
||||
active: keyset.active.unwrap_or(true),
|
||||
input_fee_ppk: keyset.input_fee_ppk,
|
||||
final_expiry: keyset.final_expiry,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let options = SendOptions {
|
||||
conditions: Some(SpendingConditions::new_p2pk(p2pk, None)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let token = wallet
|
||||
.prepare_send(Amount::from(amount), options)
|
||||
.await?
|
||||
.confirm(None)
|
||||
.await?;
|
||||
let proofs = token.proofs(&infos)?;
|
||||
|
||||
let mut tags = Vec::new();
|
||||
for proof in &proofs {
|
||||
tags.push(serde_json::to_string(proof)?);
|
||||
}
|
||||
|
||||
let event = keys::build_nutzap_event(
|
||||
&ctx.signer,
|
||||
recipient,
|
||||
&mint_tag,
|
||||
&tags,
|
||||
target,
|
||||
comment.as_deref().unwrap_or(""),
|
||||
)
|
||||
.await?;
|
||||
ctx.client.send_event(&event).await?;
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint_url)?,
|
||||
&ctx.previous_ids(&mint_url),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::Out,
|
||||
amount,
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::NutzapSent { update, amount })
|
||||
}
|
||||
Job::Receive { mint, token } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let received = wallet.receive(&token, ReceiveOptions::default()).await?;
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::In,
|
||||
amount: u64::from(received),
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Received {
|
||||
update,
|
||||
amount: u64::from(received),
|
||||
})
|
||||
}
|
||||
Job::RedeemNutzap {
|
||||
mint,
|
||||
proofs,
|
||||
nutzap_id,
|
||||
sender,
|
||||
} => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let options = ReceiveOptions {
|
||||
p2pk_signing_keys: vec![ctx.wallet_key.clone()],
|
||||
..Default::default()
|
||||
};
|
||||
let received = wallet.receive_proofs(proofs, options, None, None).await?;
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::In,
|
||||
amount: u64::from(received),
|
||||
redeemed: vec![nutzap_id],
|
||||
sender: Some(sender),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::NutzapRedeemed {
|
||||
update,
|
||||
nutzap_id,
|
||||
amount: u64::from(received),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct TokenUpdate {
|
||||
pub(crate) event_id: EventId,
|
||||
pub(crate) token: TokenEvent,
|
||||
pub(crate) destroyed: Vec<EventId>,
|
||||
}
|
||||
|
||||
pub(crate) enum Outcome {
|
||||
/// Nothing changed (e.g. an unpaid quote)
|
||||
Idle,
|
||||
/// Token events published by a reconcile pass
|
||||
Reconciled {
|
||||
updates: Vec<TokenUpdate>,
|
||||
},
|
||||
QuoteCreated {
|
||||
mint: MintUrl,
|
||||
quote_id: String,
|
||||
request: String,
|
||||
amount: u64,
|
||||
},
|
||||
QuoteMinted {
|
||||
quote_id: String,
|
||||
update: TokenUpdate,
|
||||
amount: u64,
|
||||
},
|
||||
Melted {
|
||||
update: TokenUpdate,
|
||||
amount: u64,
|
||||
},
|
||||
Sent {
|
||||
update: TokenUpdate,
|
||||
token: String,
|
||||
},
|
||||
NutzapSent {
|
||||
update: TokenUpdate,
|
||||
amount: u64,
|
||||
},
|
||||
Received {
|
||||
update: TokenUpdate,
|
||||
amount: u64,
|
||||
},
|
||||
NutzapRedeemed {
|
||||
update: TokenUpdate,
|
||||
nutzap_id: EventId,
|
||||
amount: u64,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct JobCtx<S> {
|
||||
pub(crate) wallets: HashMap<MintUrl, Wallet>,
|
||||
/// Wallet signing key, unlocking P2PK-locked nutzap proofs
|
||||
pub(crate) wallet_key: SecretKey,
|
||||
pub(crate) signer: S,
|
||||
pub(crate) client: Client,
|
||||
/// Relay token events at schedule time.
|
||||
pub(crate) tokens: HashMap<EventId, TokenEvent>,
|
||||
}
|
||||
|
||||
impl<S> JobCtx<S>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
|
||||
{
|
||||
fn wallet(&self, mint: &MintUrl) -> Result<&Wallet, Error> {
|
||||
self.wallets
|
||||
.get(mint)
|
||||
.ok_or_else(|| anyhow!("no open wallet for mint {mint}"))
|
||||
}
|
||||
|
||||
fn previous_ids(&self, mint: &MintUrl) -> Vec<EventId> {
|
||||
previous_ids(&self.tokens, mint)
|
||||
}
|
||||
|
||||
/// Publish the store's current unspent set as a fresh token event.
|
||||
async fn sync_token_events(
|
||||
&self,
|
||||
wallet: &Wallet,
|
||||
mint: Url,
|
||||
previous: &[EventId],
|
||||
history: Option<keys::History>,
|
||||
) -> Result<TokenUpdate, Error> {
|
||||
let me = self.signer.get_public_key_async().await?;
|
||||
let unspent = wallet.get_unspent_proofs().await?;
|
||||
|
||||
let proofs = unspent
|
||||
.iter()
|
||||
.map(cashu_proof)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let mut token = TokenEvent::new(mint, proofs);
|
||||
token.del = previous.iter().map(|id| id.to_hex()).collect();
|
||||
let event = keys::build_token_event(&self.signer, &me, &token).await?;
|
||||
self.client.send_event(&event).await?;
|
||||
|
||||
if !previous.is_empty() {
|
||||
let deletion = keys::build_token_deletion(&self.signer, previous).await?;
|
||||
self.client.send_event(&deletion).await?;
|
||||
}
|
||||
|
||||
if let Some(history) = history {
|
||||
let history_event =
|
||||
keys::build_history_event(&self.signer, &me, &history, event.id, previous).await?;
|
||||
self.client.send_event(&history_event).await?;
|
||||
}
|
||||
|
||||
Ok(TokenUpdate {
|
||||
event_id: event.id,
|
||||
token,
|
||||
destroyed: previous.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Claim paid quotes, then converge each mint.
|
||||
async fn reconcile(self) -> Result<Outcome, Error> {
|
||||
let mut tokens = self.tokens.clone();
|
||||
let mut updates = Vec::new();
|
||||
|
||||
for (mint_url, wallet) in &self.wallets {
|
||||
if let Err(error) = wallet.recover_incomplete_sagas().await {
|
||||
log::warn!("saga recovery failed for {mint_url}: {error:#}");
|
||||
}
|
||||
|
||||
let minted = wallet.mint_unissued_quotes().await?;
|
||||
if minted == Amount::ZERO {
|
||||
continue;
|
||||
}
|
||||
|
||||
let update = self
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(mint_url)?,
|
||||
&previous_ids(&tokens, mint_url),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::In,
|
||||
amount: u64::from(minted),
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for id in &update.destroyed {
|
||||
tokens.remove(id);
|
||||
}
|
||||
tokens.insert(update.event_id, update.token.clone());
|
||||
updates.push(update);
|
||||
}
|
||||
|
||||
for (mint_url, wallet) in &self.wallets {
|
||||
let mut relay_ys = HashSet::new();
|
||||
let mut added = Vec::new();
|
||||
|
||||
for token in tokens
|
||||
.values()
|
||||
.filter(|token| token.mint.as_str() == mint_url.to_string())
|
||||
{
|
||||
for proof in &token.proofs {
|
||||
let info = match proof_info(proof, mint_url) {
|
||||
Ok(info) => info,
|
||||
Err(error) => {
|
||||
log::warn!("skipping proof: {error:#}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if relay_ys.insert(info.y) {
|
||||
added.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let unspent = wallet.get_unspent_proofs().await?;
|
||||
let removed = unspent
|
||||
.iter()
|
||||
.filter(|proof| match proof.y() {
|
||||
Ok(y) => !relay_ys.contains(&y),
|
||||
Err(_) => false,
|
||||
})
|
||||
.map(|proof| proof.y())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
wallet.localstore.update_proofs(added, removed).await?;
|
||||
}
|
||||
|
||||
Ok(Outcome::Reconciled { updates })
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<EventId> {
|
||||
tokens
|
||||
.iter()
|
||||
.filter(|(_, token)| token.mint.as_str() == mint.to_string())
|
||||
.map(|(id, _)| *id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
|
||||
async fn recipient_wallet_info<S>(
|
||||
ctx: &JobCtx<S>,
|
||||
recipient: PublicKey,
|
||||
) -> Result<(MintUrl, String, cdk::nuts::PublicKey), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
|
||||
{
|
||||
let filter = Filter::new()
|
||||
.author(recipient)
|
||||
.kind(Kind::Custom(10019))
|
||||
.limit(1);
|
||||
|
||||
let info = match ctx
|
||||
.client
|
||||
.database()
|
||||
.query(filter.clone())
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
{
|
||||
Some(event) => event,
|
||||
None => {
|
||||
let mut stream = ctx
|
||||
.client
|
||||
.stream_events(filter)
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
.await?;
|
||||
let mut found: Option<Event> = None;
|
||||
|
||||
while let Some((_url, result)) = stream.next().await {
|
||||
if let Ok(event) = result {
|
||||
let newer = found
|
||||
.as_ref()
|
||||
.is_none_or(|current| event.created_at > current.created_at);
|
||||
|
||||
if newer {
|
||||
found = Some(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
found.ok_or_else(|| anyhow!("recipient has no wallet info"))?
|
||||
}
|
||||
};
|
||||
|
||||
let (p2pk, mints) = parse_nutzap_info(&info)?;
|
||||
|
||||
for mint in mints {
|
||||
let Ok(mint_url) = MintUrl::from_str(&mint) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if ctx.wallets.contains_key(&mint_url) {
|
||||
return Ok((mint_url, mint, p2pk));
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("no shared mint with the recipient"))
|
||||
}
|
||||
|
||||
fn parse_nutzap_info(event: &Event) -> Result<(cdk::nuts::PublicKey, Vec<String>), Error> {
|
||||
let mut p2pk = None;
|
||||
let mut mints = Vec::new();
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
let Some(value) = tag.content() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match tag.kind() {
|
||||
"pubkey" if p2pk.is_none() => {
|
||||
p2pk = Some(cdk::nuts::PublicKey::from_str(value)?);
|
||||
}
|
||||
"mint" => mints.push(value.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(p2pk) = p2pk else {
|
||||
return Err(anyhow!("recipient has no wallet pubkey"));
|
||||
};
|
||||
|
||||
Ok((p2pk, mints))
|
||||
}
|
||||
|
||||
fn cashu_proof(proof: &Proof) -> Result<CashuProof, Error> {
|
||||
Ok(CashuProof {
|
||||
id: proof.keyset_id.to_string(),
|
||||
amount: u64::from(proof.amount),
|
||||
secret: proof.secret.to_string(),
|
||||
c: proof.c.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn nostr_mint(mint: &MintUrl) -> Result<Url, Error> {
|
||||
Url::parse(&mint.to_string()).map_err(|error| anyhow!("invalid mint url: {error}"))
|
||||
}
|
||||
|
||||
fn proof_info(proof: &CashuProof, mint: &MintUrl) -> Result<ProofInfo, Error> {
|
||||
let keyset_id = Id::from_str(&proof.id)?;
|
||||
let c = cdk::nuts::PublicKey::from_str(&proof.c)?;
|
||||
let info = ProofInfo::new(
|
||||
Proof::new(
|
||||
Amount::from(proof.amount),
|
||||
keyset_id,
|
||||
Secret::new(proof.secret.clone()),
|
||||
c,
|
||||
),
|
||||
mint.clone(),
|
||||
State::Unspent,
|
||||
CurrencyUnit::Sat,
|
||||
)?;
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "nip60_ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
nip60 = { path = "../nip60" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
state = { path = "../state" }
|
||||
ui = { path = "../ui" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui-kit.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -0,0 +1,104 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, div, px};
|
||||
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::input::{InputState, NumberInput};
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::{WindowExt, h_flex, v_flex};
|
||||
use nip60::WalletRegistry;
|
||||
use nostr_sdk::prelude::{EventId, PublicKey};
|
||||
|
||||
pub mod wallet;
|
||||
|
||||
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
|
||||
|
||||
pub fn open_nutzap(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
recipient: PublicKey,
|
||||
target: Option<EventId>,
|
||||
) {
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Amount (sats)")
|
||||
.step(1.)
|
||||
.min(1.)
|
||||
});
|
||||
|
||||
let send: SubmitFn = {
|
||||
let input = input.clone();
|
||||
Rc::new(move |_, window, cx| {
|
||||
let amount = match input.read(cx).value().trim().parse::<u64>() {
|
||||
Ok(amount) if amount > 0 => amount,
|
||||
_ => {
|
||||
window.push_notification(Notification::error("Enter an amount in sats"), cx);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
window.open_alert_dialog(cx, move |this, _window, _cx| {
|
||||
this.confirm()
|
||||
.width(px(360.))
|
||||
.title(format!("Send {amount} sats?"))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Send")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.child("A nutzap cannot be undone once sent."),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| {
|
||||
let wallet = WalletRegistry::global(cx);
|
||||
wallet.update(cx, |registry, cx| {
|
||||
registry.send_nutzap(recipient, amount, target, None, cx);
|
||||
});
|
||||
|
||||
// Close the confirmation, then the amount dialog beneath it.
|
||||
window.close_dialog(cx);
|
||||
window.close_dialog(cx);
|
||||
true
|
||||
})
|
||||
});
|
||||
|
||||
// The amount dialog stays open beneath the confirmation.
|
||||
false
|
||||
})
|
||||
};
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
this.width(px(380.))
|
||||
.title("Send eCash")
|
||||
.on_ok({
|
||||
let send = send.clone();
|
||||
move |event, window, cx| send(event, window, cx)
|
||||
})
|
||||
.child(
|
||||
v_flex().gap_3().child(NumberInput::new(&input)).child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.ghost()
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
)
|
||||
.child(Button::new("send").label("Send").primary().on_click({
|
||||
let send = send.clone();
|
||||
move |event, window, cx| {
|
||||
if send(event, window, cx) {
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}
|
||||
})),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
use std::ops::Range;
|
||||
use std::rc::Rc;
|
||||
|
||||
use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry};
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
relative, uniform_list,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants as _};
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
|
||||
use gpui_kit::component::list::ListItem;
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::separator::Separator;
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme, Disableable as _, Icon, Sizable, WindowExt, h_flex, v_flex,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::Directory;
|
||||
use settings::AppSettings;
|
||||
use smallvec::SmallVec;
|
||||
use state::NostrRegistry;
|
||||
use ui::Avatar;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<WalletPanel> {
|
||||
cx.new(|cx| WalletPanel::new(window, cx))
|
||||
}
|
||||
|
||||
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
|
||||
|
||||
pub struct WalletPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl WalletPanel {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let wallet = WalletRegistry::global(cx);
|
||||
let mut subscriptions = SmallVec::new();
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&wallet,
|
||||
window,
|
||||
|_this, _wallet, event, window, cx| match event {
|
||||
WalletEvent::Updated => cx.notify(),
|
||||
WalletEvent::Error(error) => {
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
WalletEvent::TokenCreated(_) => {}
|
||||
},
|
||||
));
|
||||
|
||||
Self {
|
||||
name: "Wallet".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_balance(&self, cx: &Context<Self>) -> impl IntoElement {
|
||||
let balance = WalletRegistry::global(cx).read(cx).balance();
|
||||
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_3xl()
|
||||
.line_height(relative(1.0))
|
||||
.child(SharedString::from(balance.to_string())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.italic()
|
||||
.line_height(relative(1.0))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("sats"),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_actions(&self) -> impl IntoElement {
|
||||
h_flex().w_full().justify_center().child(
|
||||
h_flex()
|
||||
.w_80()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("send")
|
||||
.label("Send")
|
||||
.secondary()
|
||||
.rounded_full()
|
||||
.flex_1()
|
||||
.on_click(|_, window, cx| open_send_dialog(window, cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("receive")
|
||||
.label("Receive")
|
||||
.secondary()
|
||||
.rounded_full()
|
||||
.flex_1()
|
||||
.on_click(|_, window, cx| open_receive_dialog(window, cx)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_history(
|
||||
&mut self,
|
||||
history: Vec<HistoryEntry>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
if history.is_empty() {
|
||||
return v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_32()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.border_1()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.child("No transactions yet")
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
uniform_list(
|
||||
"wallet-history",
|
||||
history.len(),
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.render_history_rows(range, &history, cx)
|
||||
}),
|
||||
)
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_history_rows(
|
||||
&self,
|
||||
range: std::ops::Range<usize>,
|
||||
history: &[HistoryEntry],
|
||||
cx: &Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
range
|
||||
.map(|ix| {
|
||||
self.render_history_entry(&history[ix], cx)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_history_entry(&self, entry: &HistoryEntry, cx: &Context<Self>) -> impl IntoElement {
|
||||
let (label, color) = match entry.direction {
|
||||
TransactionDirection::In => ("Received", cx.theme().success),
|
||||
TransactionDirection::Out => ("Sent", cx.theme().danger),
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.w_full()
|
||||
.py_0p5()
|
||||
.text_xs()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(div().text_color(color).child(label))
|
||||
.child(SharedString::from(format!("{} sats", entry.amount))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(entry.created_at.to_human_time()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick a contact and an amount, then send a NIP-61 nutzap
|
||||
fn open_send_dialog(window: &mut Window, cx: &mut App) {
|
||||
let view = cx.new(|cx| SendDialog::new(window, cx));
|
||||
|
||||
window.open_dialog(cx, {
|
||||
let view = view.clone();
|
||||
move |this, _window, _cx| this.width(px(420.)).title("Send eCash").child(view.clone())
|
||||
});
|
||||
}
|
||||
|
||||
struct SendDialog {
|
||||
/// Search input, filters contacts or accepts an npub / NIP-05 address
|
||||
input: Entity<InputState>,
|
||||
amount_input: Entity<InputState>,
|
||||
/// Current user's contacts
|
||||
contacts: Option<Vec<PublicKey>>,
|
||||
/// Person directory for contact names and avatars
|
||||
directory: Entity<Directory>,
|
||||
/// The contact chosen to receive the nutzap
|
||||
selected: Option<PublicKey>,
|
||||
/// Whether a NIP-05 address is being resolved
|
||||
resolving: bool,
|
||||
error: Option<SharedString>,
|
||||
tasks: Vec<Task<Result<(), anyhow::Error>>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl SendDialog {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder("Search or enter npub or user@address.me")
|
||||
});
|
||||
let amount_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Amount (sats)")
|
||||
.step(1.)
|
||||
.min(1.)
|
||||
});
|
||||
|
||||
let subscriptions = vec![
|
||||
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.submit(window, cx);
|
||||
}
|
||||
}),
|
||||
cx.subscribe_in(&amount_input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.confirm_send(window, cx);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
// Load the contacts once the view is mounted
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load_contacts(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
input,
|
||||
amount_input,
|
||||
contacts: None,
|
||||
directory: Directory::new(cx),
|
||||
selected: None,
|
||||
resolving: false,
|
||||
error: None,
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the signed-in user's contact list.
|
||||
fn load_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.contacts.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<Vec<PublicKey>, anyhow::Error>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.author(current_user)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
let contacts: Vec<PublicKey> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|event| event.tags.public_keys().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(contacts)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let contacts = task.await;
|
||||
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
match contacts {
|
||||
Ok(contacts) => {
|
||||
this.directory.update(cx, |directory, cx| {
|
||||
directory.track(contacts.iter().copied(), cx)
|
||||
});
|
||||
this.contacts = Some(contacts);
|
||||
}
|
||||
Err(error) => this.set_error(error.to_string(), cx),
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// The contacts matching the current input, or every contact when empty.
|
||||
fn filtered_contacts(&self, cx: &Context<Self>) -> Vec<PublicKey> {
|
||||
let Some(contacts) = self.contacts.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let query = self.input.read(cx).value().trim().to_lowercase();
|
||||
|
||||
if query.is_empty() {
|
||||
return contacts.clone();
|
||||
}
|
||||
|
||||
contacts
|
||||
.iter()
|
||||
.filter(|public_key| {
|
||||
if public_key.to_hex().to_lowercase().contains(&query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let name = self.directory.read(cx).person(public_key, cx).name();
|
||||
name.to_lowercase().contains(&query)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve the input into a contact selection.
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.resolving {
|
||||
return;
|
||||
}
|
||||
|
||||
let query = self.input.read(cx).value().trim().to_string();
|
||||
|
||||
if query.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// A raw public key or an npub can be selected directly.
|
||||
if let Ok(public_key) = PublicKey::parse(&query) {
|
||||
self.select(public_key, cx);
|
||||
self.clear_input(window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// A NIP-05 address is resolved before being selected.
|
||||
if let Ok(address) = Nip05Address::parse(&query) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let task = nostr.read(cx).query_address(address, cx);
|
||||
|
||||
self.set_resolving(true, window, cx);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_resolving(false, window, cx);
|
||||
|
||||
match result {
|
||||
Ok(public_key) => {
|
||||
this.select(public_key, cx);
|
||||
this.clear_input(window, cx);
|
||||
}
|
||||
Err(error) => this.set_error(error.to_string(), cx),
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to a local search by contact name.
|
||||
let matches = self.filtered_contacts(cx);
|
||||
match matches.len() {
|
||||
0 => self.set_error("Enter a valid npub or NIP-05 address", cx),
|
||||
1 => {
|
||||
let public_key = matches[0];
|
||||
self.select(public_key, cx);
|
||||
self.clear_input(window, cx);
|
||||
}
|
||||
_ => self.set_error("Multiple contacts match, pick one from the list", cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a single recipient for the nutzap.
|
||||
fn select(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
if nostr.read(cx).current_user() == Some(public_key) {
|
||||
self.set_error("You cannot send to yourself", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
self.directory
|
||||
.update(cx, |directory, cx| directory.track([public_key], cx));
|
||||
self.selected = Some(public_key);
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Toggle a contact's selection.
|
||||
fn toggle_selection(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
||||
if self.selected == Some(*public_key) {
|
||||
self.selected = None;
|
||||
cx.notify();
|
||||
} else {
|
||||
self.select(*public_key, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the form, then confirm before the irreversible nutzap.
|
||||
fn confirm_send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(recipient) = self.selected else {
|
||||
self.set_error("Select a contact to send to", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(amount) = parse_amount(&self.amount_input, cx) else {
|
||||
self.set_error("Enter an amount in sats", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let recipient_name = self.directory.read(cx).person(&recipient, cx).name();
|
||||
|
||||
window.open_alert_dialog(cx, move |this, _window, _cx| {
|
||||
this.confirm()
|
||||
.width(px(360.))
|
||||
.title(format!("Send {amount} sats to {recipient_name}?"))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Send")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.child("A nutzap cannot be undone once sent."),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| {
|
||||
WalletRegistry::global(cx).update(cx, |registry, cx| {
|
||||
registry.send_nutzap(recipient, amount, None, None, cx);
|
||||
});
|
||||
// Close the confirmation, then the send dialog beneath it.
|
||||
window.close_dialog(cx);
|
||||
window.close_dialog(cx);
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn clear_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggle the input's loading spinner while a NIP-05 address is resolved.
|
||||
fn set_resolving(&mut self, resolving: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(resolving, window, cx);
|
||||
});
|
||||
self.resolving = resolving;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
self.error = Some(message.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_contacts(
|
||||
&self,
|
||||
range: Range<usize>,
|
||||
contacts: &[PublicKey],
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
contacts
|
||||
.get(range)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|public_key| {
|
||||
let profile = self.directory.read(cx).person(public_key, cx);
|
||||
let public_key = *public_key;
|
||||
let selected = self.selected == Some(public_key);
|
||||
let id = SharedString::from(format!("contact-{}", public_key.to_hex()));
|
||||
|
||||
let mut item = ListItem::new(id)
|
||||
.accessibility_label(profile.name())
|
||||
.selected(selected)
|
||||
.cursor_pointer()
|
||||
.w_full()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.toggle_selection(&public_key, cx);
|
||||
}));
|
||||
|
||||
if !AppSettings::get_hide_avatar(cx) {
|
||||
item = item.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.small(),
|
||||
);
|
||||
}
|
||||
|
||||
item.child(div().flex_1().min_w_0().truncate().child(profile.name()))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_selected(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(public_key) = self.selected else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let profile = self.directory.read(cx).person(&public_key, cx);
|
||||
let id = SharedString::from(format!("selected-{}", public_key.to_hex()));
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.p_0p5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().secondary)
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.xsmall(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.max_w_32()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.line_height(relative(1.0))
|
||||
.child(profile.name()),
|
||||
)
|
||||
.child(
|
||||
Button::new(id)
|
||||
.icon(IconName::Close)
|
||||
.tooltip("Remove recipient")
|
||||
.accessibility_label("Remove recipient")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.selected = None;
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SendDialog {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const NOTICE: &str = "Recipients need a NIP-60 wallet on a mint you both use.";
|
||||
|
||||
let contacts = Rc::new(self.filtered_contacts(cx));
|
||||
let contact_count = contacts.len();
|
||||
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(NumberInput::new(&self.amount_input))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(Input::new(&self.input).flex_1().cleanable(true))
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::Plus)
|
||||
.tooltip("Add person")
|
||||
.ghost()
|
||||
.large()
|
||||
.disabled(self.resolving)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.submit(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.when(self.selected.is_some(), |this| {
|
||||
this.child(self.render_selected(cx))
|
||||
})
|
||||
.child(
|
||||
uniform_list(
|
||||
"send-contacts",
|
||||
contact_count,
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.render_contacts(range, contacts.as_slice(), cx)
|
||||
}),
|
||||
)
|
||||
.h_64(),
|
||||
)
|
||||
.map(|this| {
|
||||
if let Some(error) = self.error.clone() {
|
||||
this.child(div().text_xs().text_color(cx.theme().danger).child(error))
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(NOTICE),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.secondary()
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("send")
|
||||
.label("Send")
|
||||
.primary()
|
||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.confirm_send(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a lightning invoice or redeem a pasted cashu token
|
||||
fn open_receive_dialog(window: &mut Window, cx: &mut App) {
|
||||
let amount_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Amount (sats)")
|
||||
.step(1.)
|
||||
.min(1.)
|
||||
});
|
||||
let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu"));
|
||||
|
||||
let create_invoice: SubmitFn = {
|
||||
let wallet = WalletRegistry::global(cx);
|
||||
let input = amount_input.clone();
|
||||
Rc::new(move |_, window, cx| {
|
||||
let Some(amount) = parse_amount(&input, cx) else {
|
||||
window.push_notification(Notification::error("Enter an amount in sats"), cx);
|
||||
return false;
|
||||
};
|
||||
|
||||
wallet.update(cx, |registry, cx| {
|
||||
registry.create_mint_quote(amount, cx);
|
||||
});
|
||||
|
||||
true
|
||||
})
|
||||
};
|
||||
|
||||
let redeem: SubmitFn = {
|
||||
let wallet = WalletRegistry::global(cx);
|
||||
let input = token_input.clone();
|
||||
Rc::new(move |_, window, cx| {
|
||||
let token = input.read(cx).value().trim().to_string();
|
||||
|
||||
if token.is_empty() {
|
||||
window.push_notification(Notification::error("Paste a cashu token"), cx);
|
||||
return false;
|
||||
}
|
||||
|
||||
wallet.update(cx, |registry, cx| {
|
||||
registry.receive_token(token, cx);
|
||||
});
|
||||
|
||||
true
|
||||
})
|
||||
};
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
this.width(px(420.))
|
||||
.title("Receive")
|
||||
.on_ok({
|
||||
let token_input = token_input.clone();
|
||||
let create_invoice = create_invoice.clone();
|
||||
let redeem = redeem.clone();
|
||||
move |_event, window, cx| {
|
||||
if !token_input.read(cx).value().trim().is_empty() {
|
||||
redeem(_event, window, cx)
|
||||
} else {
|
||||
create_invoice(_event, window, cx)
|
||||
}
|
||||
}
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(div().text_xs().font_semibold().child("Lightning invoice:"))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(NumberInput::new(&amount_input))
|
||||
.child(
|
||||
Button::new("invoice")
|
||||
.label("Invoice")
|
||||
.secondary()
|
||||
.w_24()
|
||||
.on_click({
|
||||
let create_invoice = create_invoice.clone();
|
||||
move |event, window, cx| {
|
||||
if create_invoice(event, window, cx) {
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(Separator::horizontal())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(div().text_xs().font_semibold().child("Cashu token:"))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(Input::new(&token_input).cleanable(true))
|
||||
.child(
|
||||
Button::new("redeem")
|
||||
.label("Redeem")
|
||||
.secondary()
|
||||
.w_24()
|
||||
.on_click({
|
||||
let redeem = redeem.clone();
|
||||
move |event, window, cx| {
|
||||
if redeem(event, window, cx) {
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_amount(input: &Entity<InputState>, cx: &App) -> Option<u64> {
|
||||
match input.read(cx).value().trim().parse::<u64>() {
|
||||
Ok(amount) if amount > 0 => Some(amount),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui_kit::component::dock::BasePanel for WalletPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"Wallet"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for WalletPanel {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Icon::new(IconName::Wallet)
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(self.name.clone())
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for WalletPanel {}
|
||||
|
||||
impl Focusable for WalletPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for WalletPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let history = WalletRegistry::global(cx).read(cx).history().to_vec();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.p_3()
|
||||
.gap_4()
|
||||
.text_xs()
|
||||
.child(self.render_balance(cx))
|
||||
.child(self.render_actions())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("History"),
|
||||
)
|
||||
.child(self.render_history(history, cx)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ impl EventEmitter<PersonEvent> for PersonRegistry {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Dispatch {
|
||||
Person(Person),
|
||||
Person(Box<Person>),
|
||||
Announcement(Event),
|
||||
Relays(Event),
|
||||
Emojis(Event),
|
||||
@@ -111,7 +111,7 @@ impl PersonRegistry {
|
||||
this.update(cx, |this, cx| {
|
||||
match event {
|
||||
Dispatch::Person(person) => {
|
||||
this.insert(person, cx);
|
||||
this.insert(*person, cx);
|
||||
}
|
||||
Dispatch::Announcement(event) => {
|
||||
this.set_announcement(&event, cx);
|
||||
@@ -175,7 +175,11 @@ impl PersonRegistry {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
let person = Person::new(event.pubkey, metadata);
|
||||
|
||||
if tx.send_async(Dispatch::Person(person)).await.is_err() {
|
||||
if tx
|
||||
.send_async(Dispatch::Person(Box::new(person)))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("Channel closed, dropping metadata event");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,13 @@ impl Person {
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
pub(crate) fn set_announcement(&mut self, announcement: Announcement) {
|
||||
// A delayed or repeated announcement must not roll the key back
|
||||
if let Some(current) = &self.announcement
|
||||
&& !announcement.is_newer_than(current)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.announcement = Some(announcement);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px};
|
||||
use gpui_kit::component::{Theme, ThemeConfig, ThemeMode, ThemeRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -12,6 +14,20 @@ use smallvec::{SmallVec, smallvec};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const SETTINGS_KEY: &str = "coop.settings";
|
||||
|
||||
/// The default base font size for the interface
|
||||
pub const DEFAULT_FONT_SIZE: f32 = 16.0;
|
||||
|
||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||
/// Minimum days between automatic encryption key rotations
|
||||
pub const NIP4E_ROTATION_MIN_DAYS: u32 = 1;
|
||||
/// Maximum days between automatic encryption key rotations
|
||||
pub const NIP4E_ROTATION_MAX_DAYS: u32 = 90;
|
||||
/// Days between automatic encryption key rotations when enabled
|
||||
pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
|
||||
/// Built-in image shown behind chat and group panels
|
||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||
|
||||
/// The user's appearance preference, persisted with the rest of the settings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum AppearanceMode {
|
||||
@@ -36,8 +52,20 @@ impl AppearanceMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// The default base font size for the interface
|
||||
pub const DEFAULT_FONT_SIZE: f32 = 16.0;
|
||||
/// Signer kind
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SignerKind {
|
||||
Auto,
|
||||
Encryption,
|
||||
#[default]
|
||||
User,
|
||||
}
|
||||
|
||||
impl SignerKind {
|
||||
pub fn encryption(&self) -> bool {
|
||||
matches!(self, SignerKind::Encryption)
|
||||
}
|
||||
}
|
||||
|
||||
/// User tuning applied on top of the native OS theme
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
@@ -63,117 +91,6 @@ impl Default for ThemeSettings {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||
}
|
||||
|
||||
/// Read the persisted appearance mode synchronously.
|
||||
fn persisted_settings() -> Option<Settings> {
|
||||
let content = persisted_content();
|
||||
content.and_then(|content| serde_json::from_str(&content).ok())
|
||||
}
|
||||
|
||||
/// The persisted settings JSON, from the config file or browser storage
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn persisted_content() -> Option<String> {
|
||||
let path = config_dir().join(".settings");
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn persisted_content() -> Option<String> {
|
||||
common::web_store::get(SETTINGS_KEY).ok().flatten()
|
||||
}
|
||||
|
||||
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 LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||
|
||||
/// Built-in image shown behind chat and group panels
|
||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||
|
||||
macro_rules! setting_accessors {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
|
||||
pub fn [<update_ $field>](value: $type, cx: &mut App) {
|
||||
Self::global(cx).update(cx, |this, cx| {
|
||||
this.inner.update(cx, |inner, cx| {
|
||||
inner.$field = value;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! setting_getters {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setting_accessors! {
|
||||
pub appearance_mode: AppearanceMode,
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub wot_auto_trust: bool,
|
||||
pub nip4e: bool,
|
||||
pub hide_group_notice: bool,
|
||||
pub file_server: Url,
|
||||
pub panel_background: bool,
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
setting_getters! {
|
||||
pub theme: ThemeSettings,
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
pub pinned_groups: Vec<String>,
|
||||
}
|
||||
|
||||
/// Signer kind
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SignerKind {
|
||||
Auto,
|
||||
Encryption,
|
||||
#[default]
|
||||
User,
|
||||
}
|
||||
|
||||
impl SignerKind {
|
||||
pub fn encryption(&self) -> bool {
|
||||
matches!(self, SignerKind::Encryption)
|
||||
}
|
||||
}
|
||||
|
||||
/// Room configuration
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct RoomConfig {
|
||||
@@ -222,12 +139,21 @@ pub struct Settings {
|
||||
pub wot_auto_trust: bool,
|
||||
/// Enable decoupling encryption key
|
||||
pub nip4e: bool,
|
||||
/// Days between automatic encryption key rotations; None disables rotation
|
||||
#[serde(default)]
|
||||
pub nip4e_rotation_days: Option<u32>,
|
||||
/// Hide the notice that messages in public groups are not encrypted
|
||||
#[serde(default)]
|
||||
pub hide_group_notice: bool,
|
||||
/// Tuning applied on top of the native OS theme
|
||||
#[serde(default)]
|
||||
pub theme: ThemeSettings,
|
||||
/// Bundled theme applied in light appearance; None uses the default light theme
|
||||
#[serde(default)]
|
||||
pub theme_light: Option<String>,
|
||||
/// Bundled theme applied in dark appearance; None uses the default dark theme
|
||||
#[serde(default)]
|
||||
pub theme_dark: Option<String>,
|
||||
/// Show an image behind chat and group panels
|
||||
#[serde(default)]
|
||||
pub panel_background: bool,
|
||||
@@ -254,8 +180,11 @@ impl Default for Settings {
|
||||
screening: true,
|
||||
wot_auto_trust: true,
|
||||
nip4e: false,
|
||||
nip4e_rotation_days: None,
|
||||
hide_group_notice: false,
|
||||
theme: ThemeSettings::default(),
|
||||
theme_light: None,
|
||||
theme_dark: None,
|
||||
panel_background: true,
|
||||
panel_background_path: None,
|
||||
trusted_relays: vec![],
|
||||
@@ -278,6 +207,64 @@ pub struct AppSettings {
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
macro_rules! setting_accessors {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
|
||||
pub fn [<update_ $field>](value: $type, cx: &mut App) {
|
||||
Self::global(cx).update(cx, |this, cx| {
|
||||
this.inner.update(cx, |inner, cx| {
|
||||
inner.$field = value;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! setting_getters {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setting_accessors! {
|
||||
pub appearance_mode: AppearanceMode,
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub wot_auto_trust: bool,
|
||||
pub nip4e: bool,
|
||||
pub nip4e_rotation_days: Option<u32>,
|
||||
pub hide_group_notice: bool,
|
||||
pub file_server: Url,
|
||||
pub panel_background: bool,
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
pub theme_light: Option<String>,
|
||||
pub theme_dark: Option<String>,
|
||||
}
|
||||
|
||||
setting_getters! {
|
||||
pub theme: ThemeSettings,
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
pub pinned_groups: Vec<String>,
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
/// Retrieve the global settings instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
@@ -330,22 +317,38 @@ impl AppSettings {
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply the persisted theme tuning on top of the active theme
|
||||
pub fn apply_theme_style(cx: &mut App) {
|
||||
use gpui_kit::component::Theme;
|
||||
Self::apply_theme_tuning(Self::get_theme(cx), cx);
|
||||
}
|
||||
|
||||
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);
|
||||
fn apply_theme_tuning(tuning: ThemeSettings, cx: &mut App) {
|
||||
{
|
||||
let styled = Theme::global_mut(cx);
|
||||
styled.font_size = px(tuning.font_size);
|
||||
styled.focus_ring = tuning.focus_ring;
|
||||
if let Some(radius) = tuning.radius {
|
||||
styled.radius = px(radius);
|
||||
}
|
||||
if let Some(radius_lg) = tuning.radius_lg {
|
||||
styled.radius_lg = px(radius_lg);
|
||||
}
|
||||
}
|
||||
if let Some(radius_lg) = theme.radius_lg {
|
||||
styled.radius_lg = px(radius_lg);
|
||||
}
|
||||
Theme::sync_base(cx);
|
||||
cx.refresh_windows();
|
||||
|
||||
Theme::update(cx, |styled| {
|
||||
let background = styled.background;
|
||||
let foreground = styled.foreground;
|
||||
let (hover_alpha, active_alpha) = if styled.is_dark() {
|
||||
(0.08, 0.12)
|
||||
} else {
|
||||
(0.04, 0.06)
|
||||
};
|
||||
if styled.list_hover == background {
|
||||
styled.list_hover = foreground.alpha(hover_alpha);
|
||||
}
|
||||
if styled.tab_active == background {
|
||||
styled.tab_active = foreground.alpha(active_alpha);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Load settings
|
||||
@@ -406,17 +409,79 @@ impl AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the persisted appearance mode to the app
|
||||
/// Apply the persisted appearance mode and theme selection to the app
|
||||
pub fn apply_theme(&mut self, cx: &mut Context<Self>) {
|
||||
use gpui_kit::component::Theme;
|
||||
|
||||
let mode = self.inner.read(cx).appearance_mode;
|
||||
match mode {
|
||||
let appearance = self.inner.read(cx).appearance_mode;
|
||||
match appearance {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
AppearanceMode::Light => Theme::change(gpui_kit::component::ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx),
|
||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
cx.refresh_windows();
|
||||
|
||||
let (light, dark) = {
|
||||
let inner = self.inner.read(cx);
|
||||
(inner.theme_light.clone(), inner.theme_dark.clone())
|
||||
};
|
||||
apply_theme_selection(light.as_deref(), dark.as_deref(), cx);
|
||||
|
||||
let tuning = self.inner.read(cx).theme;
|
||||
Self::apply_theme_tuning(tuning, cx);
|
||||
}
|
||||
|
||||
/// Select a registered theme by name.
|
||||
pub fn select_theme(name: &str, cx: &mut App) {
|
||||
let Some(config) = registered_theme(name, cx) else {
|
||||
log::warn!("theme is not registered: {name}");
|
||||
return;
|
||||
};
|
||||
|
||||
let dark = config.mode.is_dark();
|
||||
|
||||
if dark {
|
||||
Self::update_theme_dark(Some(name.to_string()), cx);
|
||||
Self::update_appearance_mode(AppearanceMode::Dark, cx);
|
||||
} else {
|
||||
Self::update_theme_light(Some(name.to_string()), cx);
|
||||
Self::update_appearance_mode(AppearanceMode::Light, cx);
|
||||
}
|
||||
|
||||
Theme::change(
|
||||
if dark {
|
||||
ThemeMode::Dark
|
||||
} else {
|
||||
ThemeMode::Light
|
||||
},
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
Self::reapply_theme(cx);
|
||||
}
|
||||
|
||||
/// Select the theme applied in light appearance; `None` restores the default
|
||||
pub fn set_theme_light(name: Option<String>, cx: &mut App) {
|
||||
Self::update_theme_light(name, cx);
|
||||
Self::reapply_theme(cx);
|
||||
}
|
||||
|
||||
/// Select the theme applied in dark appearance; `None` restores the default
|
||||
pub fn set_theme_dark(name: Option<String>, cx: &mut App) {
|
||||
Self::update_theme_dark(name, cx);
|
||||
Self::reapply_theme(cx);
|
||||
}
|
||||
|
||||
/// Re-apply the selected themes and tuning from the current settings
|
||||
fn reapply_theme(cx: &mut App) {
|
||||
let (light, dark, tuning) = AppSettings::global(cx).read_with(cx, |this, cx| {
|
||||
let inner = this.inner.read(cx);
|
||||
(
|
||||
inner.theme_light.clone(),
|
||||
inner.theme_dark.clone(),
|
||||
inner.theme,
|
||||
)
|
||||
});
|
||||
|
||||
apply_theme_selection(light.as_deref(), dark.as_deref(), cx);
|
||||
Self::apply_theme_tuning(tuning, cx);
|
||||
}
|
||||
|
||||
/// Check if decoupling encryption key is enabled
|
||||
@@ -474,6 +539,116 @@ impl AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||
}
|
||||
|
||||
/// Register the bundled gpui-kit themes with the theme registry
|
||||
pub fn init_themes(cx: &mut App) {
|
||||
let assets = cx.asset_source().clone();
|
||||
|
||||
for path in assets.list("themes").unwrap_or_default() {
|
||||
let Some(content) = assets.load(&path).unwrap_or_default() else {
|
||||
log::warn!("bundled theme {path} is listed but missing");
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(content) = std::str::from_utf8(&content) else {
|
||||
log::warn!("bundled theme {path} is not valid UTF-8");
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Err(error) = ThemeRegistry::global_mut(cx).load_themes_from_str(content) {
|
||||
log::warn!("failed to load bundled theme {path} ({error})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the persisted appearance mode synchronously.
|
||||
fn persisted_settings() -> Option<Settings> {
|
||||
let content = persisted_content();
|
||||
content.and_then(|content| serde_json::from_str(&content).ok())
|
||||
}
|
||||
|
||||
/// The persisted settings JSON, from the config file or browser storage
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn persisted_content() -> Option<String> {
|
||||
let path = config_dir().join(".settings");
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn persisted_content() -> Option<String> {
|
||||
common::web_store::get(SETTINGS_KEY).ok().flatten()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// Read the persisted theme selection for light appearance.
|
||||
pub fn persisted_theme_light() -> Option<String> {
|
||||
persisted_settings().and_then(|settings| settings.theme_light)
|
||||
}
|
||||
|
||||
/// Read the persisted theme selection for dark appearance.
|
||||
pub fn persisted_theme_dark() -> Option<String> {
|
||||
persisted_settings().and_then(|settings| settings.theme_dark)
|
||||
}
|
||||
|
||||
/// The registered theme for a persisted theme name, if any
|
||||
fn registered_theme(name: &str, cx: &App) -> Option<Rc<ThemeConfig>> {
|
||||
ThemeRegistry::global(cx).themes().get(name).cloned()
|
||||
}
|
||||
|
||||
/// Swap the selected themes into the light/dark slots and reload the active mode.
|
||||
/// An unset or unknown theme falls back to the registry's default theme.
|
||||
fn apply_theme_selection(light: Option<&str>, dark: Option<&str>, cx: &mut App) {
|
||||
let light_config = light
|
||||
.and_then(|name| registered_theme(name, cx))
|
||||
.or_else(|| Some(ThemeRegistry::global(cx).default_light_theme().clone()));
|
||||
let dark_config = dark
|
||||
.and_then(|name| registered_theme(name, cx))
|
||||
.or_else(|| Some(ThemeRegistry::global(cx).default_dark_theme().clone()));
|
||||
|
||||
{
|
||||
let theme = Theme::global_mut(cx);
|
||||
if let Some(config) = light_config {
|
||||
theme.light_theme = config;
|
||||
}
|
||||
if let Some(config) = dark_config {
|
||||
theme.dark_theme = config;
|
||||
}
|
||||
}
|
||||
|
||||
let mode = Theme::global(cx).mode;
|
||||
Theme::change(mode, None, cx);
|
||||
}
|
||||
|
||||
/// Apply the persisted appearance, theme selection and tuning synchronously.
|
||||
pub fn apply_persisted_theme(cx: &mut App) {
|
||||
match persisted_appearance_mode() {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
|
||||
let light = persisted_theme_light();
|
||||
let dark = persisted_theme_dark();
|
||||
apply_theme_selection(light.as_deref(), dark.as_deref(), cx);
|
||||
|
||||
AppSettings::apply_theme_tuning(persisted_theme(), cx);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -495,5 +670,29 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.theme, ThemeSettings::default());
|
||||
assert_eq!(settings.theme_light, None);
|
||||
assert_eq!(settings.theme_dark, None);
|
||||
}
|
||||
|
||||
/// The theme selection round-trips through serde.
|
||||
#[test]
|
||||
fn theme_selection_persists() {
|
||||
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/",
|
||||
"theme_light": "Solarized Light",
|
||||
"theme_dark": "Tokyo Night"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.theme_light.as_deref(), Some("Solarized Light"));
|
||||
assert_eq!(settings.theme_dark.as_deref(), Some("Tokyo Night"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ gpui.workspace = true
|
||||
gpui-kit = { workspace = true, features = ["speech"] }
|
||||
flume.workspace = true
|
||||
instant.workspace = true
|
||||
tts.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
common = { path = "../common" }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
pub mod model;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod parakeet;
|
||||
pub mod speaker;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tts::Tts;
|
||||
|
||||
/// Words per minute of an engine at its normal rate, for estimating speech duration.
|
||||
const NORMAL_WPM: f32 = 160.0;
|
||||
|
||||
/// Extra time past the estimate before speech is considered finished, for backends that
|
||||
/// cannot report utterance end.
|
||||
pub const END_GRACE: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Reads text aloud through the platform's speech backend, created on first use.
|
||||
pub struct Speaker {
|
||||
tts: Option<Tts>,
|
||||
end_tx: flume::Sender<()>,
|
||||
end_rx: flume::Receiver<()>,
|
||||
}
|
||||
|
||||
impl Speaker {
|
||||
pub fn new() -> Self {
|
||||
let (end_tx, end_rx) = flume::unbounded();
|
||||
|
||||
Self {
|
||||
tts: None,
|
||||
end_tx,
|
||||
end_rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cloneable stream of utterance-finish events.
|
||||
pub fn end_events(&self) -> flume::Receiver<()> {
|
||||
self.end_rx.clone()
|
||||
}
|
||||
|
||||
/// Speak the text, interrupting what this speaker is already saying.
|
||||
pub fn speak(&mut self, text: &str) -> Result<Duration, tts::Error> {
|
||||
let end_tx = self.end_tx.clone();
|
||||
let tts = match self.tts.as_ref() {
|
||||
Some(tts) => tts,
|
||||
None => self.tts.insert({
|
||||
let tts = Tts::default()?;
|
||||
if let Err(error) = tts.on_utterance_end(move |_| {
|
||||
// A send error only means the panel dropped its event loop.
|
||||
let _ = end_tx.send(());
|
||||
}) {
|
||||
log::warn!("utterance end events unavailable ({error})");
|
||||
}
|
||||
tts
|
||||
}),
|
||||
};
|
||||
tts.speak(text, true)?;
|
||||
Ok(Self::estimate(tts, text))
|
||||
}
|
||||
|
||||
/// Stop speaking, a speaker that never spoke is already silent.
|
||||
pub fn stop(&mut self) -> Result<(), tts::Error> {
|
||||
match self.tts.as_ref() {
|
||||
Some(tts) => tts.stop()?,
|
||||
None => return Ok(()),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort speaking state, a speaker that never spoke is silent.
|
||||
pub fn speaking(&self) -> bool {
|
||||
self.tts
|
||||
.as_ref()
|
||||
.and_then(|tts| tts.is_speaking().ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// How long speaking the text is expected to take at the engine's current speed.
|
||||
fn estimate(tts: &Tts, text: &str) -> Duration {
|
||||
let words = text.split_whitespace().count().max(1) as f32;
|
||||
let speed = match (tts.get_rate(), tts.normal_rate()) {
|
||||
(Ok(rate), Ok(normal)) if normal > 0. => (rate / normal).clamp(0.2, 4.),
|
||||
_ => 1.,
|
||||
};
|
||||
Duration::from_secs_f32(words / NORMAL_WPM * 60. / speed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Speaker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ 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";
|
||||
/// The storage key for the log of handled NIP-4e key-sync request ids
|
||||
const PROCESSED_REQUESTS_KEY: &str = "coop/nip4e/processed-requests";
|
||||
|
||||
/// Read the stored credential as `(username, secret)`
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -94,12 +94,12 @@ fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task<Result<()>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// Read the log of handled key-sync request ids, as concatenated hex ids
|
||||
pub fn read_processed_requests(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
|
||||
read_named(cx, PROCESSED_REQUESTS_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)
|
||||
/// Persist the log of handled key-sync request ids, as concatenated hex ids
|
||||
pub fn write_processed_requests(cx: &App, ids: &[u8]) -> Task<Result<()>> {
|
||||
write_named(cx, PROCESSED_REQUESTS_KEY, ids)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ use nostr_sdk::prelude::*;
|
||||
pub struct Announcement {
|
||||
/// The public key of the device that created this announcement.
|
||||
public_key: PublicKey,
|
||||
/// The id of the announcement event, for ordering
|
||||
event_id: EventId,
|
||||
/// The creation time of the announcement event
|
||||
created_at: Timestamp,
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
@@ -16,11 +20,20 @@ impl Announcement {
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|c| PublicKey::parse(c).ok())?;
|
||||
|
||||
Some(Self { public_key })
|
||||
Some(Self {
|
||||
public_key,
|
||||
event_id: event.id,
|
||||
created_at: event.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the public key of the device that created this announcement.
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
pub fn is_newer_than(&self, other: &Self) -> bool {
|
||||
self.created_at > other.created_at
|
||||
|| (self.created_at == other.created_at && self.event_id < other.event_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use gpui::{
|
||||
App, Div, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
|
||||
SharedString, StyleRefinement, Styled, Window, div, px,
|
||||
App, Div, Hsla, InteractiveElement, IntoElement, ParentElement, Rems, RenderOnce, SharedString,
|
||||
StyleRefinement, Styled, Window, div, rems,
|
||||
};
|
||||
use gpui_base::AvatarImage;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
@@ -115,7 +115,7 @@ impl RenderOnce for Avatar {
|
||||
#[derive(IntoElement)]
|
||||
struct PixelAvatar {
|
||||
seed: u64,
|
||||
side: Pixels,
|
||||
side: Rems,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
@@ -187,13 +187,14 @@ impl RenderOnce for PixelAvatar {
|
||||
}
|
||||
|
||||
/// The rendered side length of an avatar at `size`.
|
||||
fn side_length(size: Size) -> Pixels {
|
||||
fn side_length(size: Size) -> Rems {
|
||||
match size {
|
||||
Size::XSmall => px(16.),
|
||||
Size::Small => px(24.),
|
||||
Size::Medium => px(48.),
|
||||
Size::Large => px(80.),
|
||||
Size::Size(size) => size,
|
||||
Size::XSmall => rems(1.),
|
||||
Size::Small => rems(1.5),
|
||||
Size::Medium => rems(3.),
|
||||
Size::Large => rems(5.),
|
||||
// Custom sizes arrive in px; assume the 16px default rem.
|
||||
Size::Size(size) => rems(size.as_f32() / 16.),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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,13 +1,11 @@
|
||||
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 mod avatar;
|
||||
pub mod form;
|
||||
pub mod icon;
|
||||
pub mod message;
|
||||
pub mod text;
|
||||
pub mod window_drag;
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, Context, ElementId, InteractiveElement as _, IntoElement,
|
||||
MouseButton, MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString,
|
||||
SharedUri, StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div,
|
||||
img, px,
|
||||
Animation, AnimationExt as _, AnyElement, App, ClickEvent, Context, ElementId,
|
||||
InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ObjectFit,
|
||||
ParentElement as _, RenderOnce, SharedString, SharedUri, StatefulInteractiveElement as _,
|
||||
StyleRefinement, Styled, StyledImage, Window, div, img, rems,
|
||||
};
|
||||
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::{
|
||||
ActiveTheme, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex, v_flex,
|
||||
ActiveTheme, Disableable, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex,
|
||||
v_flex,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
@@ -197,10 +199,10 @@ impl RenderOnce for MessageRow {
|
||||
if show_author {
|
||||
match avatar {
|
||||
Some(avatar) => this.child(avatar),
|
||||
None => this.child(div().flex_shrink_0().w(px(24.))),
|
||||
None => this.child(div().flex_shrink_0().w_6()),
|
||||
}
|
||||
} else {
|
||||
this.child(div().flex_shrink_0().w(px(24.)))
|
||||
this.child(div().flex_shrink_0().w_6())
|
||||
}
|
||||
})
|
||||
.child(
|
||||
@@ -210,7 +212,7 @@ impl RenderOnce for MessageRow {
|
||||
.min_w_0()
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.text_size(px(15.))
|
||||
.text_base()
|
||||
.when(show_author, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
@@ -249,6 +251,7 @@ pub struct ReplyPreview {
|
||||
author: SharedString,
|
||||
preview: SharedString,
|
||||
on_click: Option<ClickListener>,
|
||||
on_remove: Option<ClickListener>,
|
||||
}
|
||||
|
||||
impl ReplyPreview {
|
||||
@@ -264,6 +267,7 @@ impl ReplyPreview {
|
||||
author: author.into(),
|
||||
preview: preview.into(),
|
||||
on_click: None,
|
||||
on_remove: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +280,16 @@ impl ReplyPreview {
|
||||
self.on_click = Some(Box::new(listener));
|
||||
self
|
||||
}
|
||||
|
||||
/// Show a remove button, calling the handler when it is clicked.
|
||||
#[must_use]
|
||||
pub fn on_remove(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_remove = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for ReplyPreview {
|
||||
@@ -292,6 +306,7 @@ impl RenderOnce for ReplyPreview {
|
||||
author,
|
||||
preview,
|
||||
on_click,
|
||||
on_remove,
|
||||
} = self;
|
||||
|
||||
div()
|
||||
@@ -302,8 +317,28 @@ impl RenderOnce for ReplyPreview {
|
||||
.border_color(cx.theme().list_active)
|
||||
.text_xs()
|
||||
.refine_style(&style)
|
||||
.child(div().font_semibold().child(author))
|
||||
.child(div().w_full().text_ellipsis().line_clamp(1).child(preview))
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(div().font_semibold().child(author))
|
||||
.child(div().w_full().text_ellipsis().line_clamp(1).child(preview)),
|
||||
)
|
||||
.when_some(on_remove, |this, on_remove| {
|
||||
this.child(
|
||||
Button::new("remove-reply")
|
||||
.icon(IconName::X)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.tooltip("Remove reply")
|
||||
.flex_shrink_0()
|
||||
.on_click(on_remove),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.when_some(on_click, |this, on_click| this.on_click(on_click))
|
||||
.into_any_element()
|
||||
@@ -348,7 +383,7 @@ impl RenderOnce for MessageMedia {
|
||||
img(only.clone())
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.h(px(250.))
|
||||
.h(rems(16.))
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.rounded(cx.theme().radius_lg),
|
||||
)
|
||||
@@ -363,14 +398,17 @@ impl RenderOnce for MessageMedia {
|
||||
.flex_wrap()
|
||||
.gap_2()
|
||||
.refine_style(&style)
|
||||
.children(media.into_iter().enumerate().map(|(ix, item)| {
|
||||
div().id(format!("media-{ix}")).flex_shrink_0().child(
|
||||
img(item)
|
||||
.h_32()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius_lg),
|
||||
)
|
||||
.children(media.into_iter().map(|item| {
|
||||
div()
|
||||
.id(SharedString::from(format!("media-{}", item)))
|
||||
.flex_shrink_0()
|
||||
.child(
|
||||
img(item)
|
||||
.h_32()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius_lg),
|
||||
)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -459,7 +497,7 @@ impl RenderOnce for ReactionBar {
|
||||
.when(!group.mine, |this| this.border_color(cx.theme().border))
|
||||
.when(group.image.is_none(), |this| this.child(group.emoji))
|
||||
.when_some(group.image, |this, image| {
|
||||
this.child(img(image).size(px(16.)).flex_shrink_0())
|
||||
this.child(img(image).size_4().flex_shrink_0())
|
||||
})
|
||||
.child(SharedString::from(group.count.to_string()))
|
||||
}))
|
||||
@@ -597,13 +635,14 @@ pub fn react_menu(
|
||||
})
|
||||
}
|
||||
|
||||
/// A hovered message's action bar: quick reactions, reply, copy, and an overflow menu.
|
||||
/// A hovered message's action bar: quick reactions, reply, copy, read aloud, and an overflow menu.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MessageActions {
|
||||
style: StyleRefinement,
|
||||
reactions: SmallVec<[(&'static str, SharedClickListener); 4]>,
|
||||
reply: Option<SharedClickListener>,
|
||||
copy: Option<(ElementId, SharedString)>,
|
||||
speech: Option<(bool, SharedClickListener)>,
|
||||
dropdown: Option<DropdownBuilder>,
|
||||
}
|
||||
|
||||
@@ -615,6 +654,7 @@ impl MessageActions {
|
||||
reactions: SmallVec::new(),
|
||||
reply: None,
|
||||
copy: None,
|
||||
speech: None,
|
||||
dropdown: None,
|
||||
}
|
||||
}
|
||||
@@ -642,6 +682,16 @@ impl MessageActions {
|
||||
self
|
||||
}
|
||||
|
||||
/// Add the read-aloud button, disabled while a message is already being spoken.
|
||||
pub fn speech(
|
||||
mut self,
|
||||
disabled: bool,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.speech = Some((disabled, Rc::new(handler)));
|
||||
self
|
||||
}
|
||||
|
||||
/// Fill the bar's overflow menu.
|
||||
pub fn dropdown(
|
||||
mut self,
|
||||
@@ -671,10 +721,13 @@ impl RenderOnce for MessageActions {
|
||||
reactions,
|
||||
reply,
|
||||
copy,
|
||||
speech,
|
||||
dropdown,
|
||||
} = self;
|
||||
|
||||
let has_center = reply.is_some() || copy.is_some();
|
||||
// The leading separator only separates reactions from the center buttons
|
||||
let has_center =
|
||||
!reactions.is_empty() && (reply.is_some() || copy.is_some() || speech.is_some());
|
||||
|
||||
h_flex()
|
||||
.p_0p5()
|
||||
@@ -712,6 +765,17 @@ impl RenderOnce for MessageActions {
|
||||
.when_some(copy, |this, (id, value)| {
|
||||
this.child(Clipboard::new(id).value(value).tooltip("Copy").small())
|
||||
})
|
||||
.when_some(speech, |this, (disabled, speech)| {
|
||||
this.child(
|
||||
Button::new("speech")
|
||||
.icon(IconName::Speech)
|
||||
.tooltip("Read aloud")
|
||||
.small()
|
||||
.ghost()
|
||||
.disabled(disabled)
|
||||
.on_click(move |event, window, cx| speech(event, window, cx)),
|
||||
)
|
||||
})
|
||||
.when_some(dropdown, |this, dropdown| {
|
||||
this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
||||
.child(
|
||||
@@ -726,3 +790,44 @@ impl RenderOnce for MessageActions {
|
||||
.refine_style(&style)
|
||||
}
|
||||
}
|
||||
|
||||
/// A message's text revealed word by word, paced by the estimated speech duration.
|
||||
#[derive(IntoElement)]
|
||||
pub struct RevealText {
|
||||
text: SharedString,
|
||||
duration: Duration,
|
||||
}
|
||||
|
||||
impl RevealText {
|
||||
pub fn new(text: impl Into<SharedString>, duration: Duration) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
duration,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RevealText {
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let words: Vec<SharedString> = self
|
||||
.text
|
||||
.split_whitespace()
|
||||
.map(SharedString::from)
|
||||
.collect();
|
||||
let total = words.len().max(1) as f32;
|
||||
|
||||
div().flex().flex_wrap().gap_x_1().with_animation(
|
||||
"speech-reveal",
|
||||
Animation::new(self.duration).with_max_fps(30.),
|
||||
move |this, progress| {
|
||||
let visible = ((progress * total).ceil() as usize).min(words.len());
|
||||
|
||||
this.children(words.iter().enumerate().map(|(ix, word)| {
|
||||
div()
|
||||
.when(ix >= visible, |this| this.invisible())
|
||||
.child(word.clone())
|
||||
}))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ ui = { path = "../ui" }
|
||||
gpui-kit.workspace = true
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
nip29 = { path = "../nip29" }
|
||||
nip29_ui = { path = "../nip29_ui" }
|
||||
nip4e = { path = "../nip4e" }
|
||||
chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
nip29 = { path = "../nip29" }
|
||||
nip29_ui = { path = "../nip29_ui" }
|
||||
settings = { path = "../settings" }
|
||||
speech = { path = "../speech" }
|
||||
person = { path = "../person" }
|
||||
@@ -30,3 +30,7 @@ serde.workspace = true
|
||||
log.workspace = true
|
||||
rand.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
nip60 = { path = "../nip60" }
|
||||
nip60_ui = { path = "../nip60_ui" }
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Subscription, Task, Window, div, px, relative,
|
||||
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Subscription, Task, Window, div, px,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::alert::{Alert, AlertVariant};
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_kit::component::form::{field, v_form};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, v_flex};
|
||||
use instant::Duration;
|
||||
use nostr_connect::prelude::*;
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
@@ -32,7 +36,6 @@ pub fn open_unlock(window: &mut Window, cx: &mut App) {
|
||||
this.close_button(false)
|
||||
.overlay_closable(false)
|
||||
.keyboard(false)
|
||||
.title("Unlock your identity")
|
||||
.child(import.clone())
|
||||
});
|
||||
}
|
||||
@@ -74,7 +77,15 @@ impl ImportIdentity {
|
||||
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 pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
let pass_input = cx.new(|cx| {
|
||||
let input = InputState::new(window, cx).masked(true);
|
||||
|
||||
if mode == Mode::Unlock {
|
||||
input.placeholder("Passphrase to unlock your identity")
|
||||
} else {
|
||||
input
|
||||
}
|
||||
});
|
||||
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
let error = cx.new(|_| None);
|
||||
|
||||
@@ -235,15 +246,59 @@ impl ImportIdentity {
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn render_unlock(&self, cx: &mut Context<Self>) -> Div {
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
DialogHeader::new()
|
||||
.child(DialogTitle::new().child("Unlock your identity"))
|
||||
.child(
|
||||
DialogDescription::new()
|
||||
.child("Enter the passphrase used to encrypt this identity."),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_form().child(
|
||||
field()
|
||||
.label("Passphrase")
|
||||
.required(true)
|
||||
.child(Input::new(&self.pass_input)),
|
||||
),
|
||||
)
|
||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
DialogFooter::new().child(
|
||||
Button::new("unlock")
|
||||
.primary()
|
||||
.icon(IconName::LockOpen)
|
||||
.label("Unlock")
|
||||
.loading(self.loading)
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
this.login(cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ImportIdentity {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if self.mode == Mode::Unlock {
|
||||
return self.render_unlock(cx);
|
||||
}
|
||||
|
||||
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.";
|
||||
|
||||
let is_unlock = self.mode == Mode::Unlock;
|
||||
|
||||
let value = self.key_input.read(cx).value().trim().to_string();
|
||||
let require_password = value.starts_with("ncryptsec1");
|
||||
let require_encrypt = value.starts_with("nsec1");
|
||||
@@ -257,25 +312,19 @@ impl Render for ImportIdentity {
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.when(!is_unlock, |this| {
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Use secret key or bunker connection")
|
||||
.child(Input::new(&self.key_input)),
|
||||
)
|
||||
.when(require_password, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Use secret key or bunker connection")
|
||||
.child(Input::new(&self.key_input)),
|
||||
)
|
||||
})
|
||||
.when(is_unlock || require_password, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(if is_unlock {
|
||||
"Passphrase to unlock your identity:"
|
||||
} else {
|
||||
"Decrypt Password:"
|
||||
})
|
||||
.child("Decrypt Password:")
|
||||
.child(Input::new(&self.pass_input)),
|
||||
)
|
||||
})
|
||||
@@ -297,33 +346,25 @@ impl Render for ImportIdentity {
|
||||
})
|
||||
.when(bunker_warning, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.p_2()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.bg(cx.theme().warning.opacity(0.15))
|
||||
.text_xs()
|
||||
.text_color(cx.theme().warning)
|
||||
.line_height(relative(1.2))
|
||||
.child(div().child(BUNKER_WARN)),
|
||||
Alert::new("bunker-warning", BUNKER_WARN)
|
||||
.with_variant(AlertVariant::Warning)
|
||||
.icon(IconName::TriangleAlert)
|
||||
.small(),
|
||||
)
|
||||
})
|
||||
.when(key_warning, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.p_2()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.bg(cx.theme().warning.opacity(0.15))
|
||||
.text_xs()
|
||||
.text_color(cx.theme().warning)
|
||||
.line_height(relative(1.2))
|
||||
.child(div().child(KEY_WARN)),
|
||||
Alert::new("key-warning", KEY_WARN)
|
||||
.with_variant(AlertVariant::Warning)
|
||||
.icon(IconName::TriangleAlert)
|
||||
.small(),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child({
|
||||
let content = v_flex().gap_1().child(
|
||||
Button::new("login")
|
||||
.label(if is_unlock { "Unlock" } else { "Continue" })
|
||||
.label("Continue")
|
||||
.primary()
|
||||
.loading(self.loading)
|
||||
.disabled(self.loading)
|
||||
@@ -333,30 +374,26 @@ impl Render for ImportIdentity {
|
||||
);
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let content = content.when(!is_unlock, |this| {
|
||||
this.child(
|
||||
Button::new("proxy")
|
||||
.label("Connect via Web Extension")
|
||||
.ghost()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
this.proxy(cx);
|
||||
})),
|
||||
)
|
||||
});
|
||||
let content = content.child(
|
||||
Button::new("proxy")
|
||||
.label("Connect via Web Extension")
|
||||
.ghost()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(move |this, _ev, _window, 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);
|
||||
})),
|
||||
)
|
||||
});
|
||||
let content = content.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
|
||||
})
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::input::{Input, InputState};
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::{WindowExt, h_flex, v_flex};
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
|
||||
use nip29::{GroupReference, GroupsRegistry};
|
||||
|
||||
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("naddr1…"));
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("naddr1"));
|
||||
|
||||
let join: SubmitFn = {
|
||||
let input = input.clone();
|
||||
|
||||
@@ -13,6 +13,7 @@ use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||
use gpui_kit::component::list::ListItem;
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::Directory;
|
||||
@@ -20,8 +21,6 @@ use settings::AppSettings;
|
||||
use state::NostrRegistry;
|
||||
use ui::Avatar;
|
||||
|
||||
use crate::nav::Nav;
|
||||
|
||||
/// Maximum number of members a chat can have.
|
||||
const MAX_MEMBERS: usize = 3;
|
||||
|
||||
@@ -325,27 +324,30 @@ impl NewChat {
|
||||
contacts: &[PublicKey],
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
let start = range.start;
|
||||
|
||||
contacts
|
||||
.get(range)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.map(|(offset, public_key)| {
|
||||
.map(|public_key| {
|
||||
let profile = self.directory.read(cx).person(public_key, cx);
|
||||
let public_key = *public_key;
|
||||
|
||||
Nav::new(start + offset)
|
||||
.label(profile.name())
|
||||
ListItem::new(public_key.to_hex())
|
||||
.accessibility_label(profile.name())
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.text_sm()
|
||||
.when(!AppSettings::get_hide_avatar(cx), |this| {
|
||||
this.prefix(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.small(),
|
||||
this.child(
|
||||
div().flex_shrink_0().child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.small(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(div().flex_1().min_w_0().truncate().child(profile.name()))
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.toggle_member(&public_key, cx);
|
||||
}))
|
||||
@@ -369,7 +371,7 @@ impl NewChat {
|
||||
.p_0p5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().secondary)
|
||||
.hover(|this| this.bg(cx.theme().primary))
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement,
|
||||
AnyElement, App, AppContext, Context, Div, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement, Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
|
||||
Subscription, Task, WeakEntity, Window, div, px, rems,
|
||||
};
|
||||
use gpui_base::{Popup, Select};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
|
||||
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||
use gpui_kit::component::searchable_list::{SearchableListDelegate, SearchableListItem};
|
||||
use gpui_kit::component::select::{Select, SelectEvent, SelectState};
|
||||
use gpui_kit::component::separator::Separator;
|
||||
use gpui_kit::component::switch::Switch;
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme, Disableable, IndexPath, Sizable, WindowExt, h_flex, v_flex,
|
||||
};
|
||||
use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
@@ -72,13 +74,10 @@ struct NewGroup {
|
||||
name_input: Entity<InputState>,
|
||||
about_input: Entity<TextareaState>,
|
||||
custom_relay_input: Entity<InputState>,
|
||||
/// The relays offered in the relay select.
|
||||
relays: Vec<RelayUrl>,
|
||||
/// The selected relay; `None` selects the custom relay field.
|
||||
selected_relay: Option<RelayUrl>,
|
||||
/// Whether the relay select popup is open.
|
||||
relay_select_open: bool,
|
||||
relay_options_focus: FocusHandle,
|
||||
/// The relay select; its options end with the custom relay choice.
|
||||
relay_select: Entity<SelectState<RelayDelegate>>,
|
||||
/// Keep the group in the NIP-44 encrypted section of the user's group list.
|
||||
list_private: bool,
|
||||
/// The uploaded group picture, ready to be published with the metadata.
|
||||
@@ -113,8 +112,6 @@ impl NewGroup {
|
||||
.placeholder("What is this group about?")
|
||||
});
|
||||
|
||||
let relay_options_focus = cx.focus_handle();
|
||||
|
||||
let relays: Vec<RelayUrl> = DEFAULT_RELAYS
|
||||
.iter()
|
||||
.filter_map(|url| match RelayUrl::parse(url) {
|
||||
@@ -127,9 +124,37 @@ impl NewGroup {
|
||||
.collect();
|
||||
let selected_relay = relays.first().cloned();
|
||||
|
||||
let mut options: Vec<RelayOption> = relays
|
||||
.iter()
|
||||
.map(|relay| RelayOption {
|
||||
label: relay_label(relay),
|
||||
value: Some(relay.clone()),
|
||||
})
|
||||
.collect();
|
||||
options.push(RelayOption {
|
||||
label: "Custom relay".into(),
|
||||
value: None,
|
||||
});
|
||||
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
let mut subscriptions = vec![];
|
||||
|
||||
let delegate = RelayDelegate { options };
|
||||
let selected_index = delegate.position(&selected_relay);
|
||||
let relay_select = cx.new(|cx| SelectState::new(delegate, selected_index, window, cx));
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&relay_select,
|
||||
window,
|
||||
|this, _select, event, _window, cx| {
|
||||
let SelectEvent::Confirm(value) = event;
|
||||
|
||||
if let Some(choice) = value {
|
||||
this.select_relay(choice.clone(), cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
®istry,
|
||||
window,
|
||||
@@ -165,10 +190,8 @@ impl NewGroup {
|
||||
name_input,
|
||||
about_input,
|
||||
custom_relay_input,
|
||||
relays,
|
||||
selected_relay,
|
||||
relay_select_open: false,
|
||||
relay_options_focus,
|
||||
relay_select,
|
||||
list_private: false,
|
||||
picture: None,
|
||||
uploading: false,
|
||||
@@ -275,20 +298,6 @@ impl NewGroup {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_relay_select_open(&mut self, open: bool, cx: &mut Context<Self>) {
|
||||
self.relay_select_open = open;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn activate_relay_option(&mut self, index: usize, cx: &mut Context<Self>) {
|
||||
if index < self.relays.len() {
|
||||
self.select_relay(Some(self.relays[index].clone()), cx);
|
||||
} else {
|
||||
self.select_relay(None, cx);
|
||||
}
|
||||
self.set_relay_select_open(false, cx);
|
||||
}
|
||||
|
||||
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
||||
self.uploading = uploading;
|
||||
cx.notify();
|
||||
@@ -339,7 +348,7 @@ impl NewGroup {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_relay(&self, selected: &str, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render_relay(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if let Some(parent) = &self.parent {
|
||||
return v_flex().gap_1p5().child(field_label("Relay", cx)).child(
|
||||
div()
|
||||
@@ -352,108 +361,13 @@ impl NewGroup {
|
||||
);
|
||||
}
|
||||
|
||||
let this = cx.entity().downgrade();
|
||||
let open = self.relay_select_open;
|
||||
|
||||
let trigger = h_flex()
|
||||
.id("relay-trigger")
|
||||
.justify_between()
|
||||
.w_full()
|
||||
.h_8()
|
||||
.px_3()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.cursor_default()
|
||||
.bg(if open {
|
||||
cx.theme().list_active
|
||||
} else {
|
||||
cx.theme().secondary
|
||||
})
|
||||
.when(!open, |this| {
|
||||
this.hover(|this| this.bg(cx.theme().primary))
|
||||
.active(|this| this.bg(cx.theme().list_active))
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.relay_select_open = !open;
|
||||
cx.notify();
|
||||
}))
|
||||
.child(selected.to_owned())
|
||||
.child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
);
|
||||
|
||||
let select = Select::new("relay-select")
|
||||
.open(open)
|
||||
.on_open_change({
|
||||
let this = this.clone();
|
||||
move |open, _, cx| {
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.set_relay_select_open(open, cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.content_focus_handle(&self.relay_options_focus)
|
||||
.accessibility_label("Relay")
|
||||
.accessibility_value(selected.to_owned())
|
||||
.w_full()
|
||||
.child(trigger);
|
||||
|
||||
let options = v_flex()
|
||||
.id("relay-options")
|
||||
.occlude()
|
||||
.track_focus(&self.relay_options_focus)
|
||||
.key_context("Select")
|
||||
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
|
||||
this.set_relay_select_open(false, cx);
|
||||
}))
|
||||
.bg(cx.theme().popover)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.shadow_md()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.p_1()
|
||||
.gap_y_1()
|
||||
.min_w(rems(8.))
|
||||
.text_color(cx.theme().foreground)
|
||||
.children(self.relays.iter().enumerate().map(|(index, relay)| {
|
||||
h_flex()
|
||||
.id(SharedString::from(format!("option-{relay}")))
|
||||
.px_1p5()
|
||||
.py_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.cursor_default()
|
||||
.hover(|this| this.bg(cx.theme().primary))
|
||||
.child(relay_label(relay))
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.activate_relay_option(index, cx);
|
||||
}))
|
||||
}))
|
||||
.child(Separator::horizontal())
|
||||
.child(
|
||||
h_flex()
|
||||
.id("relay-add")
|
||||
.px_1p5()
|
||||
.py_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.cursor_default()
|
||||
.child("Custom relay")
|
||||
.hover(|this| this.bg(cx.theme().primary))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.activate_relay_option(this.relays.len(), cx);
|
||||
})),
|
||||
);
|
||||
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(field_label("Relay", cx))
|
||||
.when(self.selected_relay.is_none(), |this| {
|
||||
this.child(Input::new(&self.custom_relay_input))
|
||||
})
|
||||
.child(Popup::new("relay-popup", select).when(open, |this| this.content(options)))
|
||||
.child(Select::new(&self.relay_select).accessibility_label("Relay"))
|
||||
}
|
||||
|
||||
fn render_access(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
@@ -567,12 +481,6 @@ impl NewGroup {
|
||||
|
||||
impl Render for NewGroup {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let selected = self
|
||||
.selected_relay
|
||||
.as_ref()
|
||||
.map(relay_label)
|
||||
.unwrap_or_else(|| "Add relay".into());
|
||||
|
||||
let web_url = self
|
||||
.effective_relay(cx)
|
||||
.ok()
|
||||
@@ -583,7 +491,7 @@ impl Render for NewGroup {
|
||||
.child(
|
||||
v_flex()
|
||||
.id("group-dialog-content")
|
||||
.h(px(500.))
|
||||
.h(rems(31.25))
|
||||
.gap_3()
|
||||
.when_some(self.error.clone(), |this, error| {
|
||||
this.child(self.render_error(&error, web_url.as_deref(), cx))
|
||||
@@ -614,7 +522,7 @@ impl Render for NewGroup {
|
||||
),
|
||||
)
|
||||
.child(input_field("Name", Input::new(&self.name_input), cx))
|
||||
.child(self.render_relay(&selected, cx))
|
||||
.child(self.render_relay(cx))
|
||||
.child(input_field("About", Textarea::new(&self.about_input), cx))
|
||||
.child(self.render_access(cx))
|
||||
.child(self.render_list_setting(cx))
|
||||
@@ -646,6 +554,76 @@ impl Render for NewGroup {
|
||||
}
|
||||
}
|
||||
|
||||
/// One choice in the relay select: a fixed relay, or the custom relay field.
|
||||
#[derive(Clone)]
|
||||
struct RelayOption {
|
||||
label: SharedString,
|
||||
value: Option<RelayUrl>,
|
||||
}
|
||||
|
||||
impl SearchableListItem for RelayOption {
|
||||
type Value = Option<RelayUrl>;
|
||||
|
||||
fn title(&self) -> SharedString {
|
||||
self.label.clone()
|
||||
}
|
||||
|
||||
fn value(&self) -> &Self::Value {
|
||||
&self.value
|
||||
}
|
||||
|
||||
/// The trigger reads "Add relay" while the custom relay field is active.
|
||||
fn display_title(&self) -> Option<AnyElement> {
|
||||
(self.value.is_none()).then(|| "Add relay".into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay options with a separator before the trailing custom relay choice.
|
||||
struct RelayDelegate {
|
||||
options: Vec<RelayOption>,
|
||||
}
|
||||
|
||||
impl SearchableListDelegate for RelayDelegate {
|
||||
type Item = RelayOption;
|
||||
|
||||
fn items_count(&self, _section: usize) -> usize {
|
||||
self.options.len()
|
||||
}
|
||||
|
||||
fn item(&self, ix: IndexPath) -> Option<&RelayOption> {
|
||||
self.options.get(ix.row)
|
||||
}
|
||||
|
||||
fn position<V>(&self, value: &V) -> Option<IndexPath>
|
||||
where
|
||||
Self::Item: SearchableListItem<Value = V>,
|
||||
V: PartialEq,
|
||||
{
|
||||
self.options
|
||||
.iter()
|
||||
.position(|option| option.value() == value)
|
||||
.map(IndexPath::new)
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_ix: IndexPath,
|
||||
item: &RelayOption,
|
||||
_checked: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
(item.value.is_none()).then(|| {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.child(Separator::horizontal())
|
||||
.child(item.title())
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn input_field(text: &'static str, input: impl IntoElement, cx: &App) -> Div {
|
||||
v_flex().gap_1p5().child(field_label(text, cx)).child(input)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ impl ProfileDialog {
|
||||
let bio_input = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.auto_grow(3, 8)
|
||||
.placeholder("A short introduce about you.")
|
||||
.placeholder("A short introduction about you")
|
||||
});
|
||||
|
||||
let directory = Directory::new(cx);
|
||||
@@ -438,7 +438,7 @@ impl Render for ProfileDialog {
|
||||
Button::new("update")
|
||||
.icon(IconName::SquarePen)
|
||||
.label("Update")
|
||||
.primary()
|
||||
.outline()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.toggle_edit(window, cx);
|
||||
})),
|
||||
@@ -449,7 +449,7 @@ impl Render for ProfileDialog {
|
||||
Button::new("message")
|
||||
.icon(IconName::MessageSquare)
|
||||
.label("Message")
|
||||
.primary()
|
||||
.outline()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.message(window, cx);
|
||||
})),
|
||||
|
||||
@@ -8,10 +8,9 @@ use gpui::{
|
||||
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::menu::{ContextMenu, ContextMenuExt as _, 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};
|
||||
@@ -174,7 +173,7 @@ impl RelayManager {
|
||||
})
|
||||
}
|
||||
|
||||
fn gossip_rows(&self, cx: &mut Context<Self>) -> Vec<Stateful<Div>> {
|
||||
fn gossip_rows(&self, cx: &mut Context<Self>) -> Vec<ContextMenu<Stateful<Div>>> {
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for (relay, metadata) in self.gossip.relays.iter() {
|
||||
@@ -245,7 +244,7 @@ impl RelayManager {
|
||||
})
|
||||
}
|
||||
|
||||
fn inbox_rows(&self, cx: &mut Context<Self>) -> Vec<Stateful<Div>> {
|
||||
fn inbox_rows(&self, cx: &mut Context<Self>) -> Vec<ContextMenu<Stateful<Div>>> {
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for relay in self.inbox.relays.iter() {
|
||||
@@ -353,7 +352,7 @@ impl GossipRelays {
|
||||
let metadata = self.metadata;
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
self.set_error("Relay URL is invalid", window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -365,7 +364,7 @@ impl GossipRelays {
|
||||
cx.notify();
|
||||
}
|
||||
} else {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
self.set_error("Relay URL is invalid", window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,7 +496,7 @@ impl InboxRelays {
|
||||
let value = self.input.read(cx).value().to_string();
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
self.set_error("Relay URL is invalid", window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -509,7 +508,7 @@ impl InboxRelays {
|
||||
cx.notify();
|
||||
}
|
||||
} else {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
self.set_error("Relay URL is invalid", window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,8 +634,9 @@ fn relay_row(
|
||||
badge: Option<SharedString>,
|
||||
on_remove: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
) -> ContextMenu<Stateful<Div>> {
|
||||
let label = SharedString::from(url.to_string());
|
||||
let on_remove = std::rc::Rc::new(on_remove);
|
||||
|
||||
h_flex()
|
||||
.id(label.clone())
|
||||
@@ -646,7 +646,17 @@ fn relay_row(
|
||||
.px_2()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().sidebar)
|
||||
.bg(cx.theme().secondary)
|
||||
.context_menu({
|
||||
let on_remove = on_remove.clone();
|
||||
|
||||
move |menu, _window, _cx| {
|
||||
menu.item(PopupMenuItem::new("Remove").on_click({
|
||||
let on_remove = on_remove.clone();
|
||||
move |event, window, cx| on_remove(event, window, cx)
|
||||
}))
|
||||
}
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
@@ -654,11 +664,8 @@ fn relay_row(
|
||||
.child(label)
|
||||
.when_some(badge, |this, badge| {
|
||||
this.child(
|
||||
div()
|
||||
.p_0p5()
|
||||
.rounded_xs()
|
||||
.text_xs()
|
||||
.font_medium()
|
||||
gpui_kit::component::tag::Tag::secondary()
|
||||
.xsmall()
|
||||
.child(badge),
|
||||
)
|
||||
}),
|
||||
@@ -667,10 +674,13 @@ fn relay_row(
|
||||
Button::new("remove")
|
||||
.icon(IconName::X)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.danger()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click(on_remove),
|
||||
.on_click({
|
||||
let on_remove = on_remove.clone();
|
||||
move |event, window, cx| on_remove(event, window, cx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ use anyhow::Error;
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
|
||||
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Subscription, Task, Window, div, relative, rems, uniform_list,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
@@ -313,7 +313,6 @@ impl Screening {
|
||||
.gap_1p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.hover(|this| this.bg(cx.theme().primary))
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
@@ -325,7 +324,7 @@ impl Screening {
|
||||
|
||||
items
|
||||
})
|
||||
.h(px(300.)),
|
||||
.h(rems(18.75)),
|
||||
),
|
||||
)
|
||||
});
|
||||
@@ -545,6 +544,7 @@ impl Render for Screening {
|
||||
.child(
|
||||
Button::new("mutuals")
|
||||
.icon(IconName::Info)
|
||||
.tooltip("Show mutual contacts")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.disabled(mutuals == 0)
|
||||
@@ -571,7 +571,7 @@ fn status_badge(status: Option<bool>, cx: &App) -> Div {
|
||||
if let Some(status) = status {
|
||||
this.child(Icon::new(IconName::CircleCheck).small().text_color({
|
||||
if status {
|
||||
cx.theme().primary
|
||||
cx.theme().success
|
||||
} else {
|
||||
cx.theme().muted_foreground
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::progress::Progress;
|
||||
use gpui_kit::component::separator::Separator;
|
||||
use gpui_kit::component::switch::Switch;
|
||||
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||
use settings::{AppSettings, AppearanceMode};
|
||||
use gpui_kit::component::{Disableable, Sizable, ThemeRegistry, WindowExt, h_flex, v_flex};
|
||||
use settings::{AppSettings, AppearanceMode, NIP4E_ROTATION_DEFAULT_DAYS};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use speech::model::{self as speech_model};
|
||||
use ui::{settings_block, settings_row};
|
||||
@@ -41,6 +41,8 @@ pub struct Preferences {
|
||||
radius_input: Entity<InputState>,
|
||||
radius_lg_input: Entity<InputState>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mint_input: Entity<InputState>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
_speech_poll: Task<()>,
|
||||
/// Keeps the control subscriptions alive while the dialog is open
|
||||
_subscriptions: Vec<Subscription>,
|
||||
@@ -90,6 +92,9 @@ impl Preferences {
|
||||
.max(RADIUS_RANGE.1 as f64)
|
||||
});
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint"));
|
||||
|
||||
let subscriptions = vec![
|
||||
Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| {
|
||||
if let Some(font_size) = value {
|
||||
@@ -140,6 +145,8 @@ impl Preferences {
|
||||
radius_input,
|
||||
radius_lg_input,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mint_input,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
_speech_poll,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -185,6 +192,56 @@ impl Preferences {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the NIP-60 wallet or add a mint to it
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn setup_mint(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let mint = self.mint_input.read(cx).value().trim().to_string();
|
||||
if mint.is_empty() {
|
||||
window.push_notification(Notification::error("Enter a mint URL"), cx);
|
||||
return;
|
||||
}
|
||||
|
||||
nip60::WalletRegistry::global(cx).update(cx, |registry, cx| {
|
||||
registry.setup(mint, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// The NIP-60 wallet block; absent from the web build
|
||||
fn render_wallet(
|
||||
&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"))]
|
||||
{
|
||||
const WALLET: &str = "Cashu mint for your NIP-60 wallet. Set up creates the wallet, Add extends it with another mint.";
|
||||
let has_wallet = nip60::WalletRegistry::global(cx).read(cx).has_wallet();
|
||||
|
||||
settings_block(
|
||||
"Wallet",
|
||||
WALLET,
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(div().flex_1().child(Input::new(&self.mint_input)))
|
||||
.child(
|
||||
Button::new("setup-mint")
|
||||
.label(if has_wallet { "Add" } else { "Set up" })
|
||||
.ghost()
|
||||
.size_8()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.setup_mint(window, cx);
|
||||
})),
|
||||
),
|
||||
cx,
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the appearance mode (system, light or dark)
|
||||
fn set_appearance_mode(mode: AppearanceMode, cx: &mut App) {
|
||||
AppSettings::update_appearance_mode(mode, cx);
|
||||
@@ -193,6 +250,69 @@ impl Preferences {
|
||||
});
|
||||
}
|
||||
|
||||
/// Dropdown to pick the bundled theme applied in an appearance.
|
||||
fn render_theme_choice(
|
||||
&self,
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
description: &'static str,
|
||||
dark: bool,
|
||||
cx: &Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
const DEFAULT: &str = "Default";
|
||||
|
||||
let selected = if dark {
|
||||
AppSettings::get_theme_dark(cx)
|
||||
} else {
|
||||
AppSettings::get_theme_light(cx)
|
||||
};
|
||||
|
||||
let themes: Vec<String> = ThemeRegistry::global(cx)
|
||||
.sorted_themes()
|
||||
.into_iter()
|
||||
.filter(|theme| theme.mode.is_dark() == dark)
|
||||
.map(|theme| theme.name.to_string())
|
||||
.collect();
|
||||
|
||||
let current = selected
|
||||
.filter(|name| themes.contains(name))
|
||||
.unwrap_or_else(|| DEFAULT.into());
|
||||
|
||||
settings_row(
|
||||
label,
|
||||
description,
|
||||
Button::new(id)
|
||||
.label(current)
|
||||
.ghost()
|
||||
.small()
|
||||
.dropdown_menu(move |menu, _window, _cx| {
|
||||
let reset = move |name: Option<String>, cx: &mut App| {
|
||||
if dark {
|
||||
AppSettings::set_theme_dark(name, cx);
|
||||
} else {
|
||||
AppSettings::set_theme_light(name, cx);
|
||||
}
|
||||
};
|
||||
|
||||
let menu =
|
||||
menu.item(PopupMenuItem::new(DEFAULT).on_click(move |_, _window, cx| {
|
||||
reset(None, cx);
|
||||
}));
|
||||
|
||||
themes.iter().fold(menu, |menu, name| {
|
||||
let name = name.clone();
|
||||
|
||||
menu.item(PopupMenuItem::new(name.clone()).on_click(
|
||||
move |_, _window, cx| {
|
||||
reset(Some(name.clone()), cx);
|
||||
},
|
||||
))
|
||||
})
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Ask the user for an image to show behind chat and group panels
|
||||
fn choose_background(
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))] window: &mut Window,
|
||||
@@ -326,17 +446,20 @@ impl Preferences {
|
||||
|
||||
impl Render for Preferences {
|
||||
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 a screening dialog to verify the unknown sender.";
|
||||
const WOT: &str = "Treat people followed by your contacts as known senders.";
|
||||
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
||||
const APPEARANCE: &str =
|
||||
"Choose whether the app follows the system theme or uses a light/dark theme.";
|
||||
const LIGHT_THEME: &str = "Bundled theme applied in light appearance.";
|
||||
const DARK_THEME: &str = "Bundled theme applied in dark appearance.";
|
||||
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_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it.";
|
||||
const BACKGROUND: &str = "Show an image behind chat and group panels.";
|
||||
const MEDIA: &str = "Server for media attachments. Only blossom services are supported.";
|
||||
|
||||
@@ -344,6 +467,7 @@ impl Render for Preferences {
|
||||
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let nip4e = AppSettings::get_nip4e(cx);
|
||||
let nip4e_rotation_days = AppSettings::get_nip4e_rotation_days(cx);
|
||||
let appearance_mode = AppSettings::get_appearance_mode(cx);
|
||||
let theme = AppSettings::get_theme(cx);
|
||||
let panel_background = AppSettings::get_panel_background(cx);
|
||||
@@ -351,11 +475,11 @@ impl Render for Preferences {
|
||||
|
||||
let background_label = match &panel_background_path {
|
||||
Some(path) => path.display().to_string(),
|
||||
None => String::from("Default Background"),
|
||||
None => String::from("Default background"),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.mt_2()
|
||||
.my_2()
|
||||
.gap_4()
|
||||
.w_full()
|
||||
.child(
|
||||
@@ -373,7 +497,7 @@ impl Render for Preferences {
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Web of Trust",
|
||||
"Web of trust",
|
||||
WOT,
|
||||
Switch::new("wot-auto-trust")
|
||||
.checked(wot_auto_trust)
|
||||
@@ -412,31 +536,33 @@ impl Render for Preferences {
|
||||
}),
|
||||
cx,
|
||||
))
|
||||
.child(self.render_theme_choice("theme-light", "Light theme", LIGHT_THEME, false, cx))
|
||||
.child(self.render_theme_choice("theme-dark", "Dark theme", DARK_THEME, true, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(settings_row(
|
||||
"Font Size",
|
||||
"Font size",
|
||||
FONT_SIZE,
|
||||
NumberInput::new(&self.font_size_input).w_24(),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Corner Radius",
|
||||
"Corner radius",
|
||||
RADIUS,
|
||||
NumberInput::new(&self.radius_input).w_24(),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Large Corner Radius",
|
||||
"Large corner radius",
|
||||
RADIUS_LG,
|
||||
NumberInput::new(&self.radius_lg_input).w_24(),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Focus Ring",
|
||||
"Focus ring",
|
||||
FOCUS_RING,
|
||||
Switch::new("focus-ring")
|
||||
.checked(theme.focus_ring)
|
||||
@@ -498,7 +624,7 @@ impl Render for Preferences {
|
||||
)
|
||||
.child(Separator::horizontal())
|
||||
.child(settings_row(
|
||||
"Decoupling Encryption Key",
|
||||
"Decoupling encryption key",
|
||||
NIP4E,
|
||||
Switch::new("nip4e")
|
||||
.checked(nip4e)
|
||||
@@ -507,12 +633,28 @@ impl Render for Preferences {
|
||||
}),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Rotate encryption key",
|
||||
NIP4E_ROTATION,
|
||||
Switch::new("nip4e_rotation")
|
||||
.checked(nip4e_rotation_days.is_some())
|
||||
.on_click(move |_, _window, cx| {
|
||||
let enabled = AppSettings::get_nip4e_rotation_days(cx).is_some();
|
||||
let days = if enabled {
|
||||
None
|
||||
} else {
|
||||
Some(NIP4E_ROTATION_DEFAULT_DAYS)
|
||||
};
|
||||
AppSettings::update_nip4e_rotation_days(days, cx);
|
||||
}),
|
||||
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 upload service",
|
||||
MEDIA,
|
||||
h_flex()
|
||||
.gap_1()
|
||||
@@ -520,6 +662,7 @@ impl Render for Preferences {
|
||||
.child(
|
||||
Button::new("update-file-server")
|
||||
.icon(IconName::Check)
|
||||
.tooltip("Update")
|
||||
.ghost()
|
||||
.size_8()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
@@ -528,5 +671,7 @@ impl Render for Preferences {
|
||||
),
|
||||
cx,
|
||||
))
|
||||
.child(Separator::horizontal())
|
||||
.child(self.render_wallet(cx))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::cell::Cell;
|
||||
use std::ops::Deref as _;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -166,7 +165,7 @@ impl CoopDockSkin {
|
||||
.on_drag(ResizePanel, move |info, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
shared.resizing_dock().set(Some(placement));
|
||||
cx.new(|_| info.deref().clone())
|
||||
cx.new(|_| info.clone())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,7 +678,7 @@ impl TabGroupRenderer for CoopTabGroupSkin {
|
||||
h_flex()
|
||||
.id("tabs-inner")
|
||||
.relative()
|
||||
.gap(px(4.))
|
||||
.gap_1()
|
||||
.overflow_x_scroll()
|
||||
.lock_scroll_axis()
|
||||
.track_scroll(&self.scroll_handle)
|
||||
|
||||
@@ -4,6 +4,7 @@ use gpui::{
|
||||
StatefulInteractiveElement, Styled, Window, div, px,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::tooltip::Tooltip;
|
||||
use gpui_kit::component::{ActiveTheme as _, Icon, Sizable as _, h_flex};
|
||||
|
||||
use super::TAB_BAR_HEIGHT;
|
||||
@@ -53,6 +54,15 @@ impl ControlIcon {
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Minimize => "Minimize",
|
||||
Self::Restore => "Restore",
|
||||
Self::Maximize => "Maximize",
|
||||
Self::Close => "Close",
|
||||
}
|
||||
}
|
||||
|
||||
fn window_control_area(&self) -> gpui::WindowControlArea {
|
||||
match self {
|
||||
Self::Minimize => gpui::WindowControlArea::Min,
|
||||
@@ -102,6 +112,8 @@ impl RenderOnce for ControlIcon {
|
||||
let active_bg = self.active_bg(cx);
|
||||
let icon = self.clone();
|
||||
|
||||
let label = self.label();
|
||||
|
||||
div()
|
||||
.id(self.id())
|
||||
.flex()
|
||||
@@ -112,6 +124,7 @@ impl RenderOnce for ControlIcon {
|
||||
.content_center()
|
||||
.items_center()
|
||||
.text_color(cx.theme().foreground)
|
||||
.tooltip(move |window, cx| Tooltip::new(label).build(window, cx))
|
||||
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
|
||||
.active(|style| style.bg(active_bg).text_color(hover_fg))
|
||||
.when(is_windows, |this| {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::settings::{AppSettings, AppearanceMode};
|
||||
@@ -8,13 +10,19 @@ use chat::{ChatEvent, ChatRegistry};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use common::download_dir;
|
||||
use gpui::{
|
||||
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
Pixels, Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
Action, App, AppContext, Context, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
ParentElement, Pixels, Render, SharedString, Styled, Subscription, Task, Window, div, px, rems,
|
||||
};
|
||||
use gpui_kit::component::button::Button;
|
||||
use gpui_kit::component::button::{Button, ButtonVariant};
|
||||
use gpui_kit::component::command::{
|
||||
Command as CommandPalette, CommandGroup, CommandItem, CommandState,
|
||||
};
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||
use gpui_kit::component::notification::{Notification, NotificationType};
|
||||
use gpui_kit::component::{ActiveTheme, Theme, WindowExt, v_flex};
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme, IndexPath, Theme, ThemeConfig, ThemeRegistry, WindowExt, v_flex,
|
||||
};
|
||||
use nip4e::{DeviceEvent, DeviceRegistry};
|
||||
use nip29::{GroupId, GroupKey};
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -29,7 +37,6 @@ use crate::sidebar::Sidebar;
|
||||
|
||||
mod dialogs;
|
||||
mod dock;
|
||||
mod nav;
|
||||
mod panels;
|
||||
mod sidebar;
|
||||
|
||||
@@ -57,10 +64,12 @@ enum Command {
|
||||
ShowRelayList,
|
||||
ShowProfile,
|
||||
ShowSettings,
|
||||
ToggleTheme,
|
||||
ShowBackup,
|
||||
ShowContactList,
|
||||
ShowInbox,
|
||||
ShowBrowse,
|
||||
ShowWallet,
|
||||
NewChat,
|
||||
NewGroup,
|
||||
JoinGroup,
|
||||
@@ -91,11 +100,12 @@ impl Workspace {
|
||||
let workspace = cx.entity().downgrade();
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
|
||||
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
|
||||
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
|
||||
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
}));
|
||||
}
|
||||
AppSettings::apply_theme_style(cx);
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to the nostr events
|
||||
@@ -195,6 +205,30 @@ impl Workspace {
|
||||
}),
|
||||
);
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let wallet = ::nip60::WalletRegistry::global(cx);
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&wallet,
|
||||
window,
|
||||
|_this, _wallet, event, window, cx| match event {
|
||||
::nip60::WalletEvent::Error(error) => {
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
::nip60::WalletEvent::TokenCreated(_) => {
|
||||
window.push_notification(
|
||||
Notification::new()
|
||||
.message("Cashu token created, copy it from the wallet panel")
|
||||
.with_type(NotificationType::Success),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
::nip60::WalletEvent::Updated => {}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
App::on_action(cx, {
|
||||
let workspace = workspace.clone();
|
||||
move |action, cx| {
|
||||
@@ -284,6 +318,9 @@ impl Workspace {
|
||||
.child(view.clone())
|
||||
});
|
||||
}
|
||||
Command::ToggleTheme => {
|
||||
self.theme_selector(window, cx);
|
||||
}
|
||||
Command::ShowProfile => {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
@@ -306,6 +343,13 @@ impl Workspace {
|
||||
let panel = browse::init(self.dock.downgrade(), window, cx);
|
||||
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
||||
}
|
||||
Command::ShowWallet => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let panel = nip60_ui::wallet::init(window, cx);
|
||||
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
||||
}
|
||||
}
|
||||
Command::NewChat => {
|
||||
new_chat::open(self.dock.downgrade(), window, cx);
|
||||
}
|
||||
@@ -407,6 +451,100 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse and apply the bundled themes.
|
||||
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let state = cx.new(|cx| CommandState::new(window, cx));
|
||||
let focus_on_mount = Rc::new(Cell::new(true));
|
||||
|
||||
window.open_dialog(cx, move |dialog, _, _| {
|
||||
let state = state.clone();
|
||||
let focus_on_mount = focus_on_mount.clone();
|
||||
|
||||
dialog
|
||||
.close_button(false)
|
||||
.p_0()
|
||||
.width(px(420.))
|
||||
.content(move |content, window, cx| {
|
||||
if focus_on_mount.replace(false) {
|
||||
let state = state.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
state.read(cx).focus_handle(cx).focus(window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
let active = Theme::global(cx).theme_name().clone();
|
||||
let (lights, darks) = Self::theme_entries(cx);
|
||||
|
||||
content.child(
|
||||
CommandPalette::new(&state)
|
||||
.bordered(false)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.placeholder("Search themes...")
|
||||
.max_h(rems(26.25))
|
||||
.on_confirm(move |index, _, cx| {
|
||||
if let Some(name) = Self::theme_name_at(index, cx) {
|
||||
AppSettings::select_theme(&name, cx);
|
||||
}
|
||||
})
|
||||
.group(
|
||||
CommandGroup::new()
|
||||
.label("Light Themes")
|
||||
.items(Self::theme_items(&lights, &active)),
|
||||
)
|
||||
.group(
|
||||
CommandGroup::new()
|
||||
.label("Dark Themes")
|
||||
.items(Self::theme_items(&darks, &active)),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Sorted bundled themes partitioned into light and dark groups
|
||||
fn theme_entries(cx: &App) -> (Vec<Rc<ThemeConfig>>, Vec<Rc<ThemeConfig>>) {
|
||||
let (mut lights, mut darks) = (vec![], vec![]);
|
||||
|
||||
for theme in ThemeRegistry::global(cx).sorted_themes() {
|
||||
if theme.mode.is_dark() {
|
||||
darks.push(theme.clone());
|
||||
} else {
|
||||
lights.push(theme.clone());
|
||||
}
|
||||
}
|
||||
|
||||
(lights, darks)
|
||||
}
|
||||
|
||||
/// One palette row per theme.
|
||||
fn theme_items(themes: &[Rc<ThemeConfig>], active: &SharedString) -> Vec<CommandItem> {
|
||||
themes
|
||||
.iter()
|
||||
.map(|theme| {
|
||||
CommandItem::new()
|
||||
.label(theme.name.clone())
|
||||
.checked(&theme.name == active)
|
||||
.keywords([if theme.mode.is_dark() {
|
||||
"dark"
|
||||
} else {
|
||||
"light"
|
||||
}])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The theme registered at the palette's original group position.
|
||||
fn theme_name_at(index: IndexPath, cx: &App) -> Option<SharedString> {
|
||||
let (lights, darks) = Self::theme_entries(cx);
|
||||
|
||||
match index.section {
|
||||
0 => lights.get(index.row),
|
||||
1 => darks.get(index.row),
|
||||
_ => None,
|
||||
}
|
||||
.map(|theme| theme.name.clone())
|
||||
}
|
||||
|
||||
/// Open the channel creation dialog under the action's group.
|
||||
fn on_create_channel(
|
||||
&mut self,
|
||||
@@ -480,6 +618,12 @@ impl Workspace {
|
||||
.child(SharedString::from(ENC_WARN)),
|
||||
),
|
||||
)
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Reset key")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.on_ok(move |_ev, _window, cx| {
|
||||
ent.update(cx, |this, cx| {
|
||||
this.set_announcement(Keys::generate(), cx);
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
|
||||
Styled, Window, div,
|
||||
};
|
||||
use gpui_kit::component::{ActiveTheme, Selectable, StyledExt as _, h_flex};
|
||||
|
||||
/// A single navigation row, such as an entry in a sidebar list.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Nav {
|
||||
id: ElementId,
|
||||
interactivity: Interactivity,
|
||||
style: StyleRefinement,
|
||||
prefix: Option<AnyElement>,
|
||||
label: SharedString,
|
||||
suffix: Option<AnyElement>,
|
||||
selected: bool,
|
||||
clickable: bool,
|
||||
center_prefix: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl Nav {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
let id = id.into();
|
||||
let mut interactivity = Interactivity::default();
|
||||
interactivity.element_id = Some(id.clone());
|
||||
|
||||
Self {
|
||||
id,
|
||||
interactivity,
|
||||
style: StyleRefinement::default(),
|
||||
prefix: None,
|
||||
label: SharedString::default(),
|
||||
suffix: None,
|
||||
selected: false,
|
||||
clickable: false,
|
||||
center_prefix: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the row as clickable even without an `on_click` handler, keeping the hover affordance.
|
||||
pub fn clickable(mut self, clickable: bool) -> Self {
|
||||
self.clickable = clickable;
|
||||
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.
|
||||
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||
self.prefix = Some(prefix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the row's label.
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = label.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the element shown after the label, such as a timestamp or badge.
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for Nav {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveElement for Nav {
|
||||
fn interactivity(&mut self) -> &mut Interactivity {
|
||||
&mut self.interactivity
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Nav {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Nav {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let clickable = self.on_click.is_some() || self.clickable;
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.when_some(self.prefix, |this, prefix| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.when(self.center_prefix, |this| this.w_6().justify_center())
|
||||
.child(prefix),
|
||||
)
|
||||
})
|
||||
.child(div().flex_1().min_w_0().truncate().child(self.label))
|
||||
.when_some(self.suffix, |this, suffix| {
|
||||
this.child(div().flex_shrink_0().child(suffix))
|
||||
})
|
||||
.when(clickable, |this| {
|
||||
this.cursor_pointer()
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.when(self.selected, |this| this.bg(cx.theme().list_active))
|
||||
})
|
||||
.when_some(self.on_click, |this, handler| {
|
||||
this.on_click(move |event, window, cx| handler(event, window, cx))
|
||||
})
|
||||
.refine_style(&self.style)
|
||||
}
|
||||
}
|
||||
@@ -296,7 +296,7 @@ impl BrowsePanel {
|
||||
.max_w_64()
|
||||
.px_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().sidebar)
|
||||
.bg(cx.theme().secondary)
|
||||
.child(Input::new(&self.input).appearance(false).flex_1())
|
||||
.child(
|
||||
Button::new("relay-browse")
|
||||
@@ -376,7 +376,7 @@ impl BrowsePanel {
|
||||
.cursor_pointer()
|
||||
.border_1()
|
||||
.border_color(transparent_black())
|
||||
.bg(cx.theme().sidebar)
|
||||
.bg(cx.theme().group_box)
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().secondary)
|
||||
.border_color(cx.theme().border)
|
||||
@@ -510,12 +510,12 @@ impl Render for BrowsePanel {
|
||||
} else {
|
||||
let text = if discover {
|
||||
if self.discovering {
|
||||
"Discovering groups…"
|
||||
"Discovering groups"
|
||||
} else {
|
||||
"No groups found."
|
||||
}
|
||||
} else if self.loading {
|
||||
"Loading groups…"
|
||||
"Loading groups"
|
||||
} else {
|
||||
"No groups found."
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||
use gpui_kit::component::scroll::ScrollableElement;
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||
use instant::Duration;
|
||||
@@ -229,6 +230,7 @@ impl ContactListPanel {
|
||||
|
||||
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
|
||||
let mut items = Vec::new();
|
||||
let panel = cx.weak_entity();
|
||||
|
||||
for (ix, public_key) in self.contacts.iter().enumerate() {
|
||||
let profile = self.directory.read(cx).person(public_key, cx);
|
||||
@@ -243,7 +245,24 @@ impl ContactListPanel {
|
||||
.px_2()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().primary))
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.context_menu({
|
||||
let panel = panel.clone();
|
||||
let public_key = *public_key;
|
||||
|
||||
move |menu, _window, _cx| {
|
||||
menu.item(PopupMenuItem::new("Remove").on_click({
|
||||
let panel = panel.clone();
|
||||
move |_, _window, cx| {
|
||||
if let Err(error) = panel.update(cx, |panel, cx| {
|
||||
panel.remove(&public_key, cx);
|
||||
}) {
|
||||
log::warn!("removing a contact failed: {error}");
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -256,18 +275,21 @@ impl ContactListPanel {
|
||||
.child(profile.name()),
|
||||
)
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
.icon(IconName::X)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click({
|
||||
let public_key = public_key.to_owned();
|
||||
cx.listener(move |this, _ev, _window, cx| {
|
||||
this.remove(&public_key, cx);
|
||||
})
|
||||
}),
|
||||
Button::new(SharedString::from(format!(
|
||||
"remove-{}",
|
||||
public_key.to_hex()
|
||||
)))
|
||||
.icon(IconName::X)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click({
|
||||
let public_key = public_key.to_owned();
|
||||
cx.listener(move |this, _ev, _window, cx| {
|
||||
this.remove(&public_key, cx);
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -285,7 +307,7 @@ impl ContactListPanel {
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.text_xs()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(SharedString::from("Please add some relays."))
|
||||
.child(SharedString::from("No contacts yet"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||
use gpui_kit::component::list::ListItem;
|
||||
use gpui_kit::component::scroll::ScrollableElement;
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -23,7 +24,6 @@ use ui::Avatar;
|
||||
|
||||
use super::browse;
|
||||
use crate::dock::add_panel_to;
|
||||
use crate::nav::Nav;
|
||||
use crate::sidebar::open_room;
|
||||
|
||||
/// How many requests and follow ups to show per section.
|
||||
@@ -213,17 +213,17 @@ impl GreeterPanel {
|
||||
} else {
|
||||
requests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (room, preview))| {
|
||||
.map(|(room, preview)| {
|
||||
let room = room.clone();
|
||||
let dock = dock.clone();
|
||||
let name = room.read(cx).display_name(cx);
|
||||
let seed = room.read(cx).display_image_seed(cx);
|
||||
let created_at = room.read(cx).created_at.to_ago();
|
||||
let id = room.read(cx).id;
|
||||
let preview = preview.clone();
|
||||
|
||||
h_flex()
|
||||
.id(format!("request-{index}"))
|
||||
.id(format!("request-{id}"))
|
||||
.group("")
|
||||
.w_full()
|
||||
.h_9()
|
||||
@@ -280,8 +280,8 @@ impl GreeterPanel {
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("response-{index}"))
|
||||
.label("Response")
|
||||
Button::new(format!("response-{id}"))
|
||||
.label("Respond")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
@@ -332,17 +332,17 @@ impl GreeterPanel {
|
||||
let rows: Vec<AnyElement> = self
|
||||
.follow_ups
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (room, preview))| {
|
||||
.map(|(room, preview)| {
|
||||
let room = room.clone();
|
||||
let dock = dock.clone();
|
||||
let name = room.read(cx).display_name(cx);
|
||||
let seed = room.read(cx).display_image_seed(cx);
|
||||
let created_at = room.read(cx).created_at.to_ago();
|
||||
let id = room.read(cx).id;
|
||||
let preview = preview.clone();
|
||||
|
||||
h_flex()
|
||||
.id(format!("follow-up-{index}"))
|
||||
.id(format!("follow-up-{id}"))
|
||||
.group("")
|
||||
.w_full()
|
||||
.h_9()
|
||||
@@ -399,7 +399,7 @@ impl GreeterPanel {
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("reply-{index}"))
|
||||
Button::new(format!("reply-{id}"))
|
||||
.label("Reply")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
@@ -457,9 +457,19 @@ impl GreeterPanel {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.child(
|
||||
Nav::new("browse-group")
|
||||
.prefix(Icon::new(IconName::Compass))
|
||||
.label("Browse Public Groups")
|
||||
ListItem::new("browse-group")
|
||||
.accessibility_label("Browse Public Groups")
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.child(div().flex_shrink_0().child(Icon::new(IconName::Compass)))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.child("Browse Public Groups"),
|
||||
)
|
||||
.text_sm()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
let panel = browse::init(this.dock.clone(), window, cx);
|
||||
@@ -473,9 +483,19 @@ impl GreeterPanel {
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Nav::new("browse-posts")
|
||||
.prefix(Icon::new(IconName::Link))
|
||||
.label("Browse Nostr via Jumble")
|
||||
ListItem::new("browse-posts")
|
||||
.accessibility_label("Browse Nostr via Jumble")
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.child(div().flex_shrink_0().child(Icon::new(IconName::Link)))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.child("Browse Nostr via Jumble"),
|
||||
)
|
||||
.text_sm()
|
||||
.on_click(|_, _, cx| {
|
||||
cx.open_url(JUMBLE_URL);
|
||||
|
||||
@@ -6,18 +6,19 @@ use gpui::{
|
||||
AnyElement, ClipboardItem, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement, SharedString, Styled, WeakEntity, div, px, relative, uniform_list,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::list::ListItem;
|
||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||
use nip29::Group;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use state::NostrRegistry;
|
||||
use ui::{Avatar, IconName};
|
||||
use ui::Avatar;
|
||||
|
||||
use super::Sidebar;
|
||||
use crate::dialogs::profile;
|
||||
use crate::nav::Nav;
|
||||
|
||||
/// A collapsible section of the group view.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -222,19 +223,25 @@ impl Sidebar {
|
||||
GroupSection::Members => self.members_open,
|
||||
};
|
||||
let icon = if open {
|
||||
IconName::CaretDown
|
||||
IconName::ChevronDown
|
||||
} else {
|
||||
IconName::CaretRight
|
||||
IconName::ChevronRight
|
||||
};
|
||||
let label = section.label();
|
||||
|
||||
Nav::new(label)
|
||||
.label(label)
|
||||
.suffix(
|
||||
Icon::new(icon)
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
ListItem::new(label)
|
||||
.accessibility_label(label)
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.child(div().flex_1().min_w_0().truncate().child(label))
|
||||
.suffix(move |_, cx| {
|
||||
div().flex_shrink_0().child(
|
||||
Icon::new(icon)
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
})
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
@@ -262,6 +269,7 @@ impl Sidebar {
|
||||
let group = group.clone();
|
||||
|
||||
let public_key = *public_key;
|
||||
let member = person.name();
|
||||
let member_is_admin = !roles.is_empty();
|
||||
|
||||
let is_self = NostrRegistry::global(cx)
|
||||
@@ -269,31 +277,40 @@ impl Sidebar {
|
||||
.current_user()
|
||||
.is_some_and(|me| me == public_key);
|
||||
|
||||
let nav = Nav::new(public_key.to_hex())
|
||||
.label(person.name())
|
||||
let item = ListItem::new(public_key.to_hex())
|
||||
.accessibility_label(member.clone())
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.text_sm()
|
||||
.clickable(true)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::new(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small(),
|
||||
this.child(
|
||||
div().flex_shrink_0().child(
|
||||
Avatar::new(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(div().flex_1().min_w_0().truncate().child(person.name()))
|
||||
.when(!roles.is_empty(), |this| {
|
||||
this.suffix(
|
||||
div()
|
||||
.font_normal()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(roles.join(", "))),
|
||||
)
|
||||
let roles = SharedString::from(roles.join(", "));
|
||||
|
||||
this.suffix(move |_, cx| {
|
||||
div().flex_shrink_0().child(
|
||||
div()
|
||||
.font_normal()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(roles.clone()),
|
||||
)
|
||||
})
|
||||
});
|
||||
let row = div()
|
||||
.id(public_key.to_hex())
|
||||
.w_full()
|
||||
.child(nav)
|
||||
.child(item)
|
||||
.context_menu(move |menu, _window, _cx| {
|
||||
let menu = menu
|
||||
.item(
|
||||
@@ -326,13 +343,21 @@ impl Sidebar {
|
||||
})
|
||||
});
|
||||
|
||||
menu.separator()
|
||||
.item(PopupMenuItem::new("Remove").on_click({
|
||||
menu.separator().item({
|
||||
let member = member.clone();
|
||||
PopupMenuItem::new("Remove").on_click({
|
||||
let group = group.clone();
|
||||
move |_event, window, cx| {
|
||||
nip29_ui::remove_member(group.clone(), public_key, window, cx);
|
||||
nip29_ui::confirm_remove_member(
|
||||
group.clone(),
|
||||
public_key,
|
||||
member.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}))
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
row.into_any_element()
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use auto_update::AutoUpdater;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui::{AnyElement, Entity, IntoElement};
|
||||
use gpui::{
|
||||
App, Context, Div, InteractiveElement, ParentElement, Stateful, Styled, Window, div, px,
|
||||
Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString,
|
||||
Stateful, Styled, Window, div, px,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui::{AnyElement, Entity};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui_kit::component::Disableable;
|
||||
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
||||
use gpui_kit::component::clipboard::Clipboard;
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::list::ListItem;
|
||||
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
||||
use gpui_kit::component::popover::Popover;
|
||||
use gpui_kit::component::separator::Separator;
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::shorten_pubkey;
|
||||
use settings::AppSettings;
|
||||
use state::{CLIENT_NAME, NostrRegistry};
|
||||
use ui::{Avatar, IconName};
|
||||
use ui::Avatar;
|
||||
|
||||
use super::Sidebar;
|
||||
use crate::Command;
|
||||
use crate::dock::TAB_BAR_HEIGHT;
|
||||
use crate::nav::Nav;
|
||||
|
||||
impl Sidebar {
|
||||
pub(super) fn render_user(
|
||||
@@ -31,6 +39,10 @@ impl Sidebar {
|
||||
let updater = AutoUpdater::try_global(cx);
|
||||
let profile = self.directory.read(cx).person(current_user, cx);
|
||||
let avatar = profile.avatar();
|
||||
let encryption_key = profile
|
||||
.announcement()
|
||||
.map(|announcement| announcement.public_key());
|
||||
let encryption_enabled = AppSettings::get_nip4e(cx);
|
||||
|
||||
h_flex()
|
||||
.id("sidebar-user")
|
||||
@@ -50,6 +62,7 @@ impl Sidebar {
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
this.menu("Profile", Box::new(Command::ShowProfile))
|
||||
.menu("Relays", Box::new(Command::ShowRelayList))
|
||||
.menu("Themes", Box::new(Command::ToggleTheme))
|
||||
.separator()
|
||||
.label(format!("{CLIENT_NAME} {}", env!("CARGO_PKG_VERSION")))
|
||||
.item(
|
||||
@@ -71,6 +84,11 @@ impl Sidebar {
|
||||
});
|
||||
|
||||
actions
|
||||
.when(encryption_enabled, |actions| {
|
||||
actions.when_some(encryption_key, |actions, encryption_key| {
|
||||
actions.child(self.render_encryption(encryption_key))
|
||||
})
|
||||
})
|
||||
.child(
|
||||
Button::new("sidebar-search")
|
||||
.icon(IconName::Search)
|
||||
@@ -84,7 +102,7 @@ impl Sidebar {
|
||||
)
|
||||
.child(
|
||||
Button::new("sidebar-new")
|
||||
.icon(IconName::PlusChat)
|
||||
.icon(IconName::MessageSquarePlus)
|
||||
.tooltip("New")
|
||||
.ghost()
|
||||
.small()
|
||||
@@ -111,7 +129,90 @@ impl Sidebar {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context<Self>) -> Div {
|
||||
/// The encryption key popover: public key with copy, backup and reset
|
||||
fn render_encryption(&self, encryption_key: PublicKey) -> impl IntoElement {
|
||||
Popover::new("sidebar-encryption")
|
||||
.anchor(Anchor::TopRight)
|
||||
.trigger(
|
||||
Button::new("encryption-key")
|
||||
.icon(IconName::Lock)
|
||||
.tooltip("Encryption Key")
|
||||
.ghost()
|
||||
.small(),
|
||||
)
|
||||
.content(move |_, _, cx| {
|
||||
let popover = cx.entity();
|
||||
let Ok(npub) = encryption_key.to_bech32();
|
||||
|
||||
v_flex()
|
||||
.w_64()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Public key"),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(shorten_pubkey(encryption_key, 12))),
|
||||
)
|
||||
.child(
|
||||
Clipboard::new("encryption-key-copy")
|
||||
.tooltip("Copy public key")
|
||||
.value(npub),
|
||||
),
|
||||
)
|
||||
.child(Separator::horizontal())
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("encryption-backup")
|
||||
.label("Backup")
|
||||
.outline()
|
||||
.small()
|
||||
.on_click({
|
||||
let popover = popover.clone();
|
||||
move |_, window, cx| {
|
||||
popover
|
||||
.update(cx, |state, cx| state.dismiss(window, cx));
|
||||
window.dispatch_action(
|
||||
Box::new(Command::BackupEncryption),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("encryption-reset")
|
||||
.label("Reset")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(move |_, window, cx| {
|
||||
popover.update(cx, |state, cx| state.dismiss(window, cx));
|
||||
window.dispatch_action(
|
||||
Box::new(Command::ResetEncryption),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn render_footer(&self, loading: bool, cx: &mut Context<Self>) -> Div {
|
||||
v_flex()
|
||||
.p_2()
|
||||
.flex_shrink_0()
|
||||
@@ -120,10 +221,19 @@ impl Sidebar {
|
||||
.justify_start()
|
||||
.when(!loading, |this| {
|
||||
this.child(
|
||||
Nav::new("settings")
|
||||
.prefix(Icon::new(IconName::Settings).small())
|
||||
.center_prefix(true)
|
||||
.label("Settings")
|
||||
ListItem::new("settings")
|
||||
.accessibility_label("Settings")
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
div()
|
||||
.w_6()
|
||||
.justify_center()
|
||||
.flex_shrink_0()
|
||||
.child(Icon::new(IconName::Settings).small()),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().truncate().child("Settings"))
|
||||
.text_sm()
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(Command::ShowSettings), cx)
|
||||
@@ -143,7 +253,7 @@ impl Sidebar {
|
||||
.when(!up_to_date, |this| {
|
||||
this.child(
|
||||
Button::new("update")
|
||||
.icon(IconName::ArrowDownCircle)
|
||||
.icon(IconName::CircleArrowDown)
|
||||
.tooltip(status)
|
||||
.small()
|
||||
.ghost()
|
||||
@@ -153,7 +263,7 @@ impl Sidebar {
|
||||
.when(staged, |this| {
|
||||
this.child(
|
||||
Button::new("restart")
|
||||
.icon(IconName::ArrowDownCircle)
|
||||
.icon(IconName::CircleArrowDown)
|
||||
.tooltip("Quit and relaunch into the installed update")
|
||||
.small()
|
||||
.ghost()
|
||||
@@ -174,7 +284,7 @@ fn open_logout_dialog(window: &mut Window, cx: &mut App) {
|
||||
|
||||
window.open_alert_dialog(cx, |alert, _, _| {
|
||||
alert
|
||||
.title("Log Out?")
|
||||
.title("Log out")
|
||||
.description(DESCRIPTION)
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
|
||||
@@ -2,19 +2,18 @@ use std::rc::Rc;
|
||||
|
||||
use chat::{ChatRegistry, RoomKind};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled, div, uniform_list,
|
||||
};
|
||||
use gpui::{AnyElement, App, Context, IntoElement, ParentElement, Styled, div, uniform_list};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::badge::Badge;
|
||||
use gpui_kit::component::list::ListItem;
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||
use nip29::GroupsRegistry;
|
||||
use settings::AppSettings;
|
||||
use ui::IconName;
|
||||
|
||||
use super::Sidebar;
|
||||
use super::rows::SidebarRow;
|
||||
use crate::Command;
|
||||
use crate::nav::Nav;
|
||||
|
||||
/// A collapsible section of the sidebar's main list.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -38,8 +37,8 @@ impl Sidebar {
|
||||
pub(super) fn render_list(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let requests_open = self.requests_open;
|
||||
let contacts_open = self.contacts_open && !requests_open;
|
||||
let new_requests = self.new_requests;
|
||||
let rows = Rc::new(self.rows_for(cx));
|
||||
|
||||
// Whether to show the create button for the contact picker
|
||||
let create_button = contacts_open && !self.selected_contacts.is_empty();
|
||||
|
||||
@@ -60,55 +59,83 @@ impl Sidebar {
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.relative()
|
||||
.border_l_1()
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
.child(
|
||||
v_flex()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.items_start()
|
||||
.justify_start()
|
||||
.when(cfg!(not(target_arch = "wasm32")), |this| {
|
||||
this.child(
|
||||
ListItem::new("wallet")
|
||||
.accessibility_label("Wallet")
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
div().flex_shrink_0().child(
|
||||
Icon::new(gpui_kit::assets::IconName::Wallet).small(),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().truncate().child("Wallet"))
|
||||
.text_sm()
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(crate::Command::ShowWallet), cx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Nav::new("browse")
|
||||
.prefix(Icon::new(IconName::Compass).small())
|
||||
.label("Browse")
|
||||
ListItem::new("browse")
|
||||
.accessibility_label("Browse")
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.child(Icon::new(IconName::Compass).small()),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().truncate().child("Browse"))
|
||||
.text_sm()
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Nav::new("reqs")
|
||||
.prefix(Icon::new(IconName::Invite).small())
|
||||
.label("Requests")
|
||||
ListItem::new("reqs")
|
||||
.accessibility_label("Requests")
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.child(Icon::new(IconName::UserPlus).small()),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().truncate().child("Requests"))
|
||||
.text_sm()
|
||||
.when(request_count > 0, |this| {
|
||||
this.suffix(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.py_0p5()
|
||||
.px_1()
|
||||
.min_w_6()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.font_medium()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.text_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().secondary)
|
||||
.child(SharedString::from(
|
||||
request_count.to_string(),
|
||||
)),
|
||||
)
|
||||
.when(self.new_requests && !requests_open, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.size_1()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().primary),
|
||||
this.suffix(move |_, cx| {
|
||||
div().flex_shrink_0().child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Badge::new()
|
||||
.count(request_count)
|
||||
.child(div().size_4()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when(new_requests && !requests_open, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.size_1()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().primary),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
.when(requests_open, |this| this.bg(cx.theme().list_active))
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
@@ -116,9 +143,17 @@ impl Sidebar {
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Nav::new("contacts")
|
||||
.prefix(Icon::new(IconName::Book).small())
|
||||
.label("Contacts")
|
||||
ListItem::new("contacts")
|
||||
.accessibility_label("Contacts")
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.child(Icon::new(IconName::Book).small()),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().truncate().child("Contacts"))
|
||||
.text_sm()
|
||||
.when(contacts_open, |this| this.bg(cx.theme().list_active))
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
|
||||
@@ -21,6 +21,7 @@ use nostr_sdk::prelude::*;
|
||||
use person::{Directory, PersonRegistry};
|
||||
pub(crate) use rows::open_room;
|
||||
use search::SearchResult;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
||||
use ui::title_bar_drag_handlers;
|
||||
@@ -104,6 +105,8 @@ impl Sidebar {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let groups = GroupsRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let settings = AppSettings::global(cx);
|
||||
let directory = Directory::new(cx);
|
||||
|
||||
let find_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
@@ -206,6 +209,11 @@ impl Sidebar {
|
||||
},
|
||||
));
|
||||
|
||||
// The user header reads settings and the tracked profiles on render, so
|
||||
// the encryption button reacts to toggles and announcement arrivals
|
||||
subscriptions.push(cx.observe(&settings, |_, _, cx| cx.notify()));
|
||||
subscriptions.push(cx.observe(&directory, |_, _, cx| cx.notify()));
|
||||
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
let timer = Duration::from_millis(PROGRESS_TICK_MS);
|
||||
loop {
|
||||
@@ -229,7 +237,7 @@ impl Sidebar {
|
||||
chats_open: None,
|
||||
contacts_open: false,
|
||||
contacts: None,
|
||||
directory: Directory::new(cx),
|
||||
directory,
|
||||
selected_contacts: HashSet::new(),
|
||||
requests_open: false,
|
||||
new_requests: false,
|
||||
@@ -396,6 +404,8 @@ impl Render for Sidebar {
|
||||
.size_full()
|
||||
.relative()
|
||||
.justify_between()
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.child(
|
||||
|
||||
@@ -5,23 +5,25 @@ use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement,
|
||||
SharedString, Styled, WeakEntity, Window, div, px,
|
||||
SharedString, Styled, WeakEntity, Window, div,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
|
||||
use gpui_kit::component::list::ListItem;
|
||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex};
|
||||
use gpui_kit::component::tag::Tag;
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex};
|
||||
use nip29::Group;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use ui::{Avatar, IconName};
|
||||
use ui::Avatar;
|
||||
|
||||
use super::Sidebar;
|
||||
use super::list::ListSection;
|
||||
use super::search::MessageHit;
|
||||
use crate::dialogs::{profile, screening};
|
||||
use crate::nav::Nav;
|
||||
|
||||
/// Open a room in the dock, screening new requests when enabled.
|
||||
pub(crate) fn open_room(
|
||||
@@ -49,7 +51,7 @@ pub(crate) fn open_room(
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Ignore")
|
||||
.ok_text("Response"),
|
||||
.ok_text("Respond"),
|
||||
)
|
||||
.on_cancel(move |_event, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
@@ -92,13 +94,10 @@ impl Sidebar {
|
||||
) -> Vec<AnyElement> {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
rows.get(range.clone())
|
||||
rows.get(range)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.map(|(offset, row)| {
|
||||
let index = range.start + offset;
|
||||
|
||||
.map(|row| {
|
||||
let element = match row {
|
||||
SidebarRow::Section(section) => {
|
||||
let section = *section;
|
||||
@@ -111,18 +110,24 @@ impl Sidebar {
|
||||
};
|
||||
|
||||
let icon = if open {
|
||||
IconName::CaretDown
|
||||
IconName::ChevronDown
|
||||
} else {
|
||||
IconName::CaretRight
|
||||
IconName::ChevronRight
|
||||
};
|
||||
|
||||
Nav::new(label)
|
||||
.label(label)
|
||||
.suffix(
|
||||
Icon::new(icon)
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
ListItem::new(label)
|
||||
.accessibility_label(label)
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.child(div().flex_1().min_w_0().truncate().child(label))
|
||||
.suffix(move |_, cx| {
|
||||
div().flex_shrink_0().child(
|
||||
Icon::new(icon)
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
})
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
@@ -143,8 +148,11 @@ impl Sidebar {
|
||||
SidebarRow::Heading(label) => {
|
||||
let label = *label;
|
||||
|
||||
Nav::new(label)
|
||||
.label(label)
|
||||
ListItem::new(label)
|
||||
.accessibility_label(label)
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(div().flex_1().min_w_0().truncate().child(label))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
@@ -171,48 +179,48 @@ impl Sidebar {
|
||||
let dock = self.dock.clone();
|
||||
let room = room.clone();
|
||||
|
||||
let nav = Nav::new(index)
|
||||
.label(name)
|
||||
let item = ListItem::new(id)
|
||||
.accessibility_label(name.clone())
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.text_sm()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::new(room.read(cx).display_image(cx))
|
||||
.seed(seed)
|
||||
.small(),
|
||||
this.child(
|
||||
div().flex_shrink_0().child(
|
||||
Avatar::new(room.read(cx).display_image(cx))
|
||||
.seed(seed)
|
||||
.small(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(div().flex_1().min_w_0().truncate().child(name))
|
||||
.suffix(move |_, cx| {
|
||||
div().flex_shrink_0().child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.when(spam, |this| {
|
||||
this.child(Tag::danger().xsmall().child("Spam"))
|
||||
})
|
||||
.when(!spam, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.font_normal()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(created_at.clone()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.suffix(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.when(spam, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.px_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().danger.opacity(0.15))
|
||||
.text_size(px(8.0))
|
||||
.text_color(cx.theme().danger)
|
||||
.child("SPAM"),
|
||||
)
|
||||
})
|
||||
.when(!spam, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.font_normal()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(created_at),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
open_room(&room, &dock, window, cx);
|
||||
});
|
||||
|
||||
div()
|
||||
.id(index)
|
||||
.id(id)
|
||||
.w_full()
|
||||
.child(nav)
|
||||
.child(item)
|
||||
.context_menu(move |menu, _window, _cx| {
|
||||
let menu = menu.item(
|
||||
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
|
||||
@@ -241,16 +249,22 @@ impl Sidebar {
|
||||
let sidebar = cx.entity().downgrade();
|
||||
let public_key = public_key.to_owned();
|
||||
|
||||
Nav::new(index)
|
||||
.label(person.name())
|
||||
ListItem::new(public_key.to_hex())
|
||||
.accessibility_label(person.name())
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.text_sm()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::new(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small(),
|
||||
this.child(
|
||||
div().flex_shrink_0().child(
|
||||
Avatar::new(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(div().flex_1().min_w_0().truncate().child(person.name()))
|
||||
.selected(selected)
|
||||
.on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = sidebar.update(cx, |this, cx| {
|
||||
@@ -265,16 +279,22 @@ impl Sidebar {
|
||||
let person = self.directory.read(cx).person(public_key, cx);
|
||||
let public_key = public_key.to_owned();
|
||||
|
||||
Nav::new(index)
|
||||
.label(person.name())
|
||||
ListItem::new(public_key.to_hex())
|
||||
.accessibility_label(person.name())
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.text_sm()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::new(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small(),
|
||||
this.child(
|
||||
div().flex_shrink_0().child(
|
||||
Avatar::new(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(div().flex_1().min_w_0().truncate().child(person.name()))
|
||||
.on_click(move |_event, window, cx| {
|
||||
profile::open(public_key, window, cx);
|
||||
})
|
||||
@@ -298,24 +318,32 @@ impl Sidebar {
|
||||
.or_else(|| group.read(cx).elsewhere().map(|_| "Moved"))
|
||||
.map(SharedString::from);
|
||||
|
||||
let nav = Nav::new(index)
|
||||
.label(name)
|
||||
let item = ListItem::new(tag.clone())
|
||||
.accessibility_label(name.clone())
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.text_sm()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::new(group.read(cx).display_image())
|
||||
.seed(seed)
|
||||
.small(),
|
||||
this.child(
|
||||
div().flex_shrink_0().child(
|
||||
Avatar::new(group.read(cx).display_image())
|
||||
.seed(seed)
|
||||
.small(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(div().flex_1().min_w_0().truncate().child(name))
|
||||
.when_some(suffix, |this, suffix| {
|
||||
this.suffix(
|
||||
div()
|
||||
.font_normal()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(suffix),
|
||||
)
|
||||
this.suffix(move |_, cx| {
|
||||
div().flex_shrink_0().child(
|
||||
div()
|
||||
.font_normal()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(suffix.clone()),
|
||||
)
|
||||
})
|
||||
})
|
||||
.on_click(move |_event, window, cx| {
|
||||
crate::dock::add_panel_to(
|
||||
@@ -334,9 +362,9 @@ impl Sidebar {
|
||||
});
|
||||
|
||||
div()
|
||||
.id(index)
|
||||
.id(tag.clone())
|
||||
.w_full()
|
||||
.child(nav)
|
||||
.child(item)
|
||||
.context_menu(move |menu, _window, _cx| {
|
||||
menu.item(
|
||||
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
|
||||
@@ -371,24 +399,32 @@ impl Sidebar {
|
||||
let room = hit.room.clone();
|
||||
let content = hit.content.clone();
|
||||
|
||||
Nav::new(index)
|
||||
.label(content)
|
||||
ListItem::new(hit.author.to_hex())
|
||||
.accessibility_label(content.clone())
|
||||
.h_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::new(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small(),
|
||||
this.child(
|
||||
div().flex_shrink_0().child(
|
||||
Avatar::new(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(div().flex_1().min_w_0().truncate().child(content))
|
||||
.suffix(move |_, cx| {
|
||||
div().flex_shrink_0().child(
|
||||
div()
|
||||
.font_normal()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(detail.clone()),
|
||||
)
|
||||
})
|
||||
.suffix(
|
||||
div()
|
||||
.font_normal()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(detail),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
open_room(&room, &dock, window, cx);
|
||||
})
|
||||
|
||||