Compare commits
12 Commits
2423cdca19
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
| b5d6d91851 | |||
| d475d03d0c | |||
| 0f00fed122 | |||
| ef73b3c629 | |||
| bbf31baee5 | |||
| 80227b3ed3 | |||
| d00c5a1982 | |||
| c054017d7e | |||
| d065e70cd1 | |||
| 7a6b6feacc | |||
| 55c5ebbf17 | |||
| 3fecda175b |
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- platform: macos-x64
|
||||
os: macos-13
|
||||
os: macos-15-intel
|
||||
target: x86_64-apple-darwin
|
||||
- platform: macos-arm64
|
||||
os: macos-latest
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Make get-crate-version executable
|
||||
run: chmod +x script/get-crate-version
|
||||
@@ -163,8 +163,6 @@ jobs:
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/**/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Output release info
|
||||
run: |
|
||||
|
||||
313
Cargo.lock
generated
313
Cargo.lock
generated
@@ -220,14 +220,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.13.2"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0848bedd08067dca1c02c31cbb371a94ad4f2f8a61a82f2c43d96ec36a395244"
|
||||
checksum = "e21900ac91937e4d9a51391f3569cd92fc38caea1a2a671d56b39797f3ece61f"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"getrandom 0.4.1",
|
||||
"getrandom 0.4.2",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"wayland-backend",
|
||||
@@ -602,9 +602,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
|
||||
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
@@ -612,9 +612,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.37.1"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
|
||||
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
@@ -772,6 +772,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
@@ -1009,10 +1018,12 @@ dependencies = [
|
||||
"gpui",
|
||||
"gpui_tokio",
|
||||
"itertools 0.13.0",
|
||||
"linkify",
|
||||
"log",
|
||||
"nostr-sdk",
|
||||
"once_cell",
|
||||
"person",
|
||||
"pulldown-cmark",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1189,7 +1200,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collections"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"rustc-hash 2.1.1",
|
||||
@@ -1232,6 +1243,7 @@ name = "common"
|
||||
version = "1.0.0-beta"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bech32",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures",
|
||||
@@ -1318,6 +1330,7 @@ dependencies = [
|
||||
"chat",
|
||||
"chat_ui",
|
||||
"common",
|
||||
"core-text",
|
||||
"device",
|
||||
"futures",
|
||||
"gpui",
|
||||
@@ -1400,19 +1413,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.0",
|
||||
"core-graphics-types 0.2.0",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics-helmer-fork"
|
||||
version = "0.24.0"
|
||||
@@ -1463,13 +1463,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "core-text"
|
||||
version = "21.1.0"
|
||||
version = "21.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fce32d657e17d6e4a8e70fe2ae6875218015f320620a78e5949d228bc76622bd"
|
||||
checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.0",
|
||||
"core-graphics 0.25.0",
|
||||
"core-graphics 0.24.0",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1646,7 +1647,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "derive_refineable"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1670,6 +1671,8 @@ dependencies = [
|
||||
"smallvec",
|
||||
"smol",
|
||||
"state",
|
||||
"theme",
|
||||
"ui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1730,6 +1733,18 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -1906,9 +1921,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "erased-serde"
|
||||
version = "0.4.9"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3"
|
||||
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
@@ -2431,6 +2446,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -2453,20 +2477,20 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"r-efi 6.0.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
@@ -2587,7 +2611,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui"
|
||||
version = "0.2.2"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel 2.5.0",
|
||||
@@ -2625,7 +2649,7 @@ dependencies = [
|
||||
"mach2",
|
||||
"media",
|
||||
"metal",
|
||||
"naga",
|
||||
"naga 28.0.0",
|
||||
"num_cpus",
|
||||
"objc",
|
||||
"parking",
|
||||
@@ -2666,7 +2690,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_linux"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"as-raw-xcb-connection",
|
||||
@@ -2714,11 +2738,10 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_macos"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-task",
|
||||
"bindgen",
|
||||
"block",
|
||||
"cbindgen",
|
||||
"cocoa 0.26.0",
|
||||
@@ -2730,6 +2753,7 @@ dependencies = [
|
||||
"core-video",
|
||||
"ctor",
|
||||
"derive_more",
|
||||
"dispatch2",
|
||||
"etagere",
|
||||
"foreign-types",
|
||||
"futures",
|
||||
@@ -2750,12 +2774,13 @@ dependencies = [
|
||||
"strum",
|
||||
"util",
|
||||
"uuid",
|
||||
"zed-font-kit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpui_macros"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -2766,7 +2791,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_platform"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"gpui",
|
||||
@@ -2779,7 +2804,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_tokio"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gpui",
|
||||
@@ -2790,7 +2815,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_util"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"log",
|
||||
@@ -2799,13 +2824,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_web"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console_error_panic_hook",
|
||||
"futures",
|
||||
"gpui",
|
||||
"gpui_wgpu",
|
||||
"http_client",
|
||||
"js-sys",
|
||||
"log",
|
||||
"parking_lot",
|
||||
@@ -2822,7 +2848,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_wgpu"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytemuck",
|
||||
@@ -2850,7 +2876,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_windows"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collections",
|
||||
@@ -2870,7 +2896,6 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-numerics 0.2.0",
|
||||
"windows-registry 0.5.3",
|
||||
"zed-scap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3094,7 +3119,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "http_client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-compression",
|
||||
@@ -3119,7 +3144,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "http_client_tls"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"rustls-platform-verifier",
|
||||
@@ -3446,9 +3471,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
@@ -3655,12 +3680,13 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.12"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.3",
|
||||
]
|
||||
|
||||
@@ -3693,6 +3719,15 @@ dependencies = [
|
||||
"rust-ini",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linkify"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -3857,6 +3892,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
@@ -3880,7 +3924,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bindgen",
|
||||
@@ -3998,6 +4042,30 @@ name = "naga"
|
||||
version = "28.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "618f667225063219ddfc61251087db8a9aec3c3f0950c916b614e403486f1135"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"codespan-reporting",
|
||||
"half",
|
||||
"hashbrown 0.16.1",
|
||||
"hexf-parse",
|
||||
"indexmap",
|
||||
"libm",
|
||||
"log",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"rustc-hash 1.1.0",
|
||||
"thiserror 2.0.18",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "28.0.1"
|
||||
source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
@@ -4107,7 +4175,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
[[package]]
|
||||
name = "nostr"
|
||||
version = "0.44.1"
|
||||
source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -4131,7 +4199,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-blossom"
|
||||
version = "0.44.0"
|
||||
source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"nostr",
|
||||
@@ -4142,7 +4210,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-connect"
|
||||
version = "0.44.0"
|
||||
source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"futures-core",
|
||||
@@ -4155,7 +4223,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-database"
|
||||
version = "0.44.0"
|
||||
source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9"
|
||||
dependencies = [
|
||||
"btreecap",
|
||||
"flatbuffers",
|
||||
@@ -4165,7 +4233,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-gossip"
|
||||
version = "0.44.0"
|
||||
source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9"
|
||||
dependencies = [
|
||||
"nostr",
|
||||
]
|
||||
@@ -4173,7 +4241,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-lmdb"
|
||||
version = "0.44.0"
|
||||
source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"flume",
|
||||
@@ -4187,7 +4255,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-sdk"
|
||||
version = "0.44.1"
|
||||
source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"async-wsocket",
|
||||
@@ -4443,7 +4511,7 @@ dependencies = [
|
||||
"endi",
|
||||
"futures-lite 2.6.1",
|
||||
"futures-util",
|
||||
"getrandom 0.4.1",
|
||||
"getrandom 0.4.2",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"md-5",
|
||||
@@ -4624,7 +4692,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
[[package]]
|
||||
name = "perf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"collections",
|
||||
"serde",
|
||||
@@ -4742,9 +4810,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand 2.3.0",
|
||||
@@ -4757,6 +4825,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.17.16"
|
||||
@@ -4956,14 +5030,30 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.27"
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
|
||||
checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"bitflags 2.11.0",
|
||||
"getopts",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
version = "0.4.1"
|
||||
@@ -5063,9 +5153,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -5076,6 +5166,12 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
@@ -5305,7 +5401,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "refineable"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"derive_refineable",
|
||||
]
|
||||
@@ -5404,7 +5500,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "reqwest_client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -5459,7 +5555,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rope"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"log",
|
||||
@@ -5721,7 +5817,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "scheduler"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"backtrace",
|
||||
@@ -6158,9 +6254,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.3.5"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f7a918bd2a9951d18ee6e48f076843e8e73a9a5d22cf05bcd4b7a81bdd04e17"
|
||||
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
@@ -6315,7 +6411,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
[[package]]
|
||||
name = "sum_tree"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"log",
|
||||
@@ -6550,7 +6646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
dependencies = [
|
||||
"fastrand 2.3.0",
|
||||
"getrandom 0.4.1",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -6732,9 +6828,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.49.0"
|
||||
version = "1.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -6747,9 +6843,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.0"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -7006,10 +7102,14 @@ version = "0.3.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
@@ -7258,7 +7358,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
[[package]]
|
||||
name = "util"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-fs",
|
||||
@@ -7297,7 +7397,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "util_macros"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"perf",
|
||||
"quote",
|
||||
@@ -7310,7 +7410,7 @@ version = "1.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
|
||||
dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
@@ -7747,9 +7847,8 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "wgpu"
|
||||
version = "28.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9cb534d5ffd109c7d1135f34cdae29e60eab94855a625dcfe1705f8bc7ad79f"
|
||||
version = "28.0.1"
|
||||
source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags 2.11.0",
|
||||
@@ -7760,7 +7859,7 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"js-sys",
|
||||
"log",
|
||||
"naga",
|
||||
"naga 28.0.1",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"profiling",
|
||||
@@ -7777,9 +7876,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core"
|
||||
version = "28.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8bb4c8b5db5f00e56f1f08869d870a0dff7c8bc7ebc01091fec140b0cf0211a9"
|
||||
version = "28.0.1"
|
||||
source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
@@ -7791,7 +7889,7 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"indexmap",
|
||||
"log",
|
||||
"naga",
|
||||
"naga 28.0.1",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
@@ -7809,36 +7907,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-apple"
|
||||
version = "28.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87b7b696b918f337c486bf93142454080a32a37832ba8a31e4f48221890047da"
|
||||
version = "28.0.1"
|
||||
source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2"
|
||||
dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-emscripten"
|
||||
version = "28.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b251c331f84feac147de3c4aa3aa45112622a95dd7ee1b74384fa0458dbd79"
|
||||
version = "28.0.1"
|
||||
source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2"
|
||||
dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-windows-linux-android"
|
||||
version = "28.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ca976e72b2c9964eb243e281f6ce7f14a514e409920920dcda12ae40febaae"
|
||||
version = "28.0.1"
|
||||
source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2"
|
||||
dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-hal"
|
||||
version = "28.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "293080d77fdd14d6b08a67c5487dfddbf874534bb7921526db56a7b75d7e3bef"
|
||||
version = "28.0.1"
|
||||
source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
@@ -7861,7 +7955,7 @@ dependencies = [
|
||||
"libloading",
|
||||
"log",
|
||||
"metal",
|
||||
"naga",
|
||||
"naga 28.0.1",
|
||||
"ndk-sys",
|
||||
"objc",
|
||||
"once_cell",
|
||||
@@ -7884,9 +7978,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-types"
|
||||
version = "28.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e18308757e594ed2cd27dddbb16a139c42a683819d32a2e0b1b0167552f5840c"
|
||||
version = "28.0.1"
|
||||
source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bytemuck",
|
||||
@@ -9100,7 +9193,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "zlog"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -9117,7 +9210,7 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
[[package]]
|
||||
name = "ztracing"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -9128,7 +9221,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ztracing_macro"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#1123140e40f47ad7b12815c16de0d49e42e36617"
|
||||
source = "git+https://github.com/zed-industries/zed#152d3eafcaf4655ac65e2a25e65cc5ee0545db3f"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
|
||||
@@ -9,17 +9,14 @@ edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["screen-capture", "x11", "wayland", "runtime_shaders"] }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland", "runtime_shaders"] }
|
||||
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_windows = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
# TODO: remove after fixed, issue: https://github.com/zed-industries/zed/issues/47168
|
||||
core-text = "=21.0.0"
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
3
assets/icons/device.svg
Normal file
3
assets/icons/device.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M14.25 10.75C14.25 9.64543 15.1454 8.75 16.25 8.75H20.25C21.3546 8.75 22.25 9.64543 22.25 10.75V19.25C22.25 20.3546 21.3546 21.25 20.25 21.25H16.25C15.1454 21.25 14.25 20.3546 14.25 19.25V10.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.25 18.25H19.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.25 8.75V5.75C20.25 4.64543 19.3546 3.75 18.25 3.75H5.75C4.64543 3.75 3.75 4.64543 3.75 5.75V14.75C3.75 15.8546 2.85457 16.75 1.75 16.75V18.25C1.75 19.3546 2.64543 20.25 3.75 20.25H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.75 16.75H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 898 B |
3
assets/icons/group.svg
Normal file
3
assets/icons/group.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="8.75" r="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="4" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="20" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.25 16.625V16.5C7.25 13.8766 9.37665 11.75 12 11.75C14.6234 11.75 16.75 13.8766 16.75 16.5V16.625C16.75 17.5225 16.0225 18.25 15.125 18.25H8.875C7.97754 18.25 7.25 17.5225 7.25 16.625Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.25 17.2602H2.75C1.64543 17.2602 0.706551 16.3538 0.919944 15.2701C1.25877 13.5493 2.15049 12.3257 4 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.75 17.2601H21.25C22.3546 17.2601 23.2935 16.3538 23.08 15.27C22.7412 13.5493 21.8495 12.3257 20 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
assets/icons/scan.svg
Normal file
3
assets/icons/scan.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M7.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V9.25M16.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V9.25M21.25 14.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H16.75M7.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V14.75M7.75 9.75V14.25M16.25 9.75V14.25M12 9.75V12.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 468 B |
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use gpui::http_client::{AsyncBody, HttpClient};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||
@@ -11,7 +11,7 @@ use gpui::{
|
||||
};
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use smol::fs::File;
|
||||
use smol::io::AsyncReadExt;
|
||||
use smol::process::Command;
|
||||
@@ -20,11 +20,11 @@ const GITHUB_API_URL: &str = "https://api.github.com";
|
||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||
|
||||
fn get_github_repo_owner() -> String {
|
||||
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "your-username".to_string())
|
||||
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
|
||||
}
|
||||
|
||||
fn get_github_repo_name() -> String {
|
||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "your-repo".to_string())
|
||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
||||
}
|
||||
|
||||
fn is_flatpak_installation() -> bool {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use common::EventUtils;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, USER_GIFTWRAP};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, RelayState, TIMEOUT, USER_GIFTWRAP};
|
||||
|
||||
mod message;
|
||||
mod room;
|
||||
@@ -113,7 +113,7 @@ impl ChatRegistry {
|
||||
subscriptions.push(
|
||||
// Observe the nip65 state and load chat rooms on every state change
|
||||
cx.observe(&nostr, |this, state, cx| {
|
||||
match state.read(cx).relay_list_state() {
|
||||
match state.read(cx).relay_list_state {
|
||||
RelayState::Idle => {
|
||||
this.reset(cx);
|
||||
}
|
||||
@@ -262,9 +262,12 @@ impl ChatRegistry {
|
||||
pub fn get_contact_list(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let Some(public_key) = signer.public_key() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
@@ -318,9 +321,12 @@ impl ChatRegistry {
|
||||
fn verify_relays(&mut self, cx: &mut Context<Self>) -> Task<Result<InboxState, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let Some(public_key) = signer.public_key() else {
|
||||
return Task::ready(Err(anyhow!("User not found")));
|
||||
};
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
@@ -685,14 +691,12 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Trigger a refresh of the opened chat rooms by their IDs
|
||||
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
|
||||
if let Some(ids) = ids {
|
||||
for room in self.rooms.iter() {
|
||||
if ids.contains(&room.read(cx).id) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.emit_refresh(cx);
|
||||
});
|
||||
}
|
||||
pub fn refresh_rooms(&mut self, ids: &[u64], cx: &mut Context<Self>) {
|
||||
for room in self.rooms.iter() {
|
||||
if ids.contains(&room.read(cx).id) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.emit_refresh(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::hash::Hash;
|
||||
use std::ops::Range;
|
||||
|
||||
use common::EventUtils;
|
||||
use common::{EventUtils, NostrParser};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// New message.
|
||||
@@ -91,6 +92,18 @@ impl PartialOrd for Message {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Mention {
|
||||
pub public_key: PublicKey,
|
||||
pub range: Range<usize>,
|
||||
}
|
||||
|
||||
impl Mention {
|
||||
pub fn new(public_key: PublicKey, range: Range<usize>) -> Self {
|
||||
Self { public_key, range }
|
||||
}
|
||||
}
|
||||
|
||||
/// Rendered message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderedMessage {
|
||||
@@ -102,7 +115,7 @@ pub struct RenderedMessage {
|
||||
/// Message created time as unix timestamp
|
||||
pub created_at: Timestamp,
|
||||
/// List of mentioned public keys in the message
|
||||
pub mentions: Vec<PublicKey>,
|
||||
pub mentions: Vec<Mention>,
|
||||
/// List of event of the message this message is a reply to
|
||||
pub replies_to: Vec<EventId>,
|
||||
}
|
||||
@@ -184,20 +197,17 @@ impl Hash for RenderedMessage {
|
||||
}
|
||||
|
||||
/// Extracts all mentions (public keys) from a content string.
|
||||
fn extract_mentions(content: &str) -> Vec<PublicKey> {
|
||||
fn extract_mentions(content: &str) -> Vec<Mention> {
|
||||
let parser = NostrParser::new();
|
||||
let tokens = parser.parse(content);
|
||||
|
||||
tokens
|
||||
.filter_map(|token| match token {
|
||||
Token::Nostr(nip21) => match nip21 {
|
||||
Nip21::Pubkey(pubkey) => Some(pubkey),
|
||||
Nip21::Profile(profile) => Some(profile.public_key),
|
||||
_ => None,
|
||||
},
|
||||
.filter_map(|token| match token.value {
|
||||
Nip21::Pubkey(public_key) => Some(Mention::new(public_key, token.range)),
|
||||
Nip21::Profile(profile) => Some(Mention::new(profile.public_key, token.range)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extracts all reply (ids) from the event tags.
|
||||
|
||||
@@ -331,7 +331,7 @@ impl Room {
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let sender = signer.public_key().unwrap();
|
||||
let sender = signer.public_key();
|
||||
|
||||
// Get room's id
|
||||
let id = self.id;
|
||||
@@ -340,7 +340,7 @@ impl Room {
|
||||
let members: Vec<PublicKey> = self
|
||||
.members
|
||||
.iter()
|
||||
.filter(|public_key| public_key != &&sender)
|
||||
.filter(|public_key| Some(**public_key) != sender)
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -28,3 +28,5 @@ serde_json.workspace = true
|
||||
|
||||
once_cell = "1.19.0"
|
||||
regex = "1"
|
||||
linkify = "0.10.0"
|
||||
pulldown-cmark = "0.13.1"
|
||||
|
||||
@@ -8,19 +8,19 @@ use chat::{Message, RenderedMessage, Room, RoomEvent, SendReport};
|
||||
use common::RenderedTimestamp;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
deferred, div, img, list, px, red, relative, svg, white, AnyElement, App, AppContext,
|
||||
ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement,
|
||||
PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled, StyledImage,
|
||||
Subscription, Task, WeakEntity, Window,
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement,
|
||||
Styled, StyledImage, Subscription, Task, WeakEntity, Window, deferred, div, img, list, px, red,
|
||||
relative, svg, white,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use settings::{AppSettings, SignerKind};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use smol::lock::RwLock;
|
||||
use state::{upload, NostrRegistry};
|
||||
use state::{NostrRegistry, upload};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
@@ -31,8 +31,8 @@ use ui::menu::{ContextMenuExt, DropdownMenu};
|
||||
use ui::notification::Notification;
|
||||
use ui::scroll::Scrollbar;
|
||||
use ui::{
|
||||
h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
|
||||
WindowExtension,
|
||||
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
|
||||
h_flex, v_flex,
|
||||
};
|
||||
|
||||
use crate::text::RenderedText;
|
||||
@@ -699,10 +699,13 @@ impl ChatPanel {
|
||||
if let Some(message) = self.messages.iter().nth(ix) {
|
||||
match message {
|
||||
Message::User(rendered) => {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let text = self
|
||||
.rendered_texts_by_id
|
||||
.entry(rendered.id)
|
||||
.or_insert_with(|| RenderedText::new(&rendered.content, cx))
|
||||
.or_insert_with(|| {
|
||||
RenderedText::new(&rendered.content, &rendered.mentions, &persons, cx)
|
||||
})
|
||||
.element(ix.into(), window, cx);
|
||||
|
||||
self.render_text_message(ix, rendered, text, cx)
|
||||
@@ -876,7 +879,7 @@ impl ChatPanel {
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
this.show_close(true)
|
||||
.title(SharedString::from("Sent Reports"))
|
||||
.child(v_flex().pb_4().gap_4().children({
|
||||
.child(v_flex().pb_2().gap_4().children({
|
||||
let mut items = Vec::with_capacity(reports.len());
|
||||
|
||||
for report in reports.iter() {
|
||||
@@ -1305,9 +1308,9 @@ impl Render for ChatPanel {
|
||||
.on_action(cx.listener(Self::on_command))
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(
|
||||
list(
|
||||
self.list_state.clone(),
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chat::Mention;
|
||||
use common::RangeExt;
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString,
|
||||
StyledText, UnderlineStyle, Window,
|
||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use person::PersonRegistry;
|
||||
use regex::Regex;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::actions::OpenPublicKey;
|
||||
|
||||
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"(?i)(?:^|\s)(?:https?://)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:/[^\s]*)?(?:\s|$)").unwrap()
|
||||
});
|
||||
|
||||
static NOSTR_URI_REGEX: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Highlight {
|
||||
Link,
|
||||
Nostr,
|
||||
Code,
|
||||
InlineCode(bool),
|
||||
Highlight(HighlightStyle),
|
||||
Mention,
|
||||
}
|
||||
|
||||
impl From<HighlightStyle> for Highlight {
|
||||
fn from(style: HighlightStyle) -> Self {
|
||||
Self::Highlight(style)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -35,7 +35,12 @@ pub struct RenderedText {
|
||||
}
|
||||
|
||||
impl RenderedText {
|
||||
pub fn new(content: &str, cx: &App) -> Self {
|
||||
pub fn new(
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
persons: &Entity<PersonRegistry>,
|
||||
cx: &App,
|
||||
) -> Self {
|
||||
let mut text = String::new();
|
||||
let mut highlights = Vec::new();
|
||||
let mut link_ranges = Vec::new();
|
||||
@@ -43,10 +48,12 @@ impl RenderedText {
|
||||
|
||||
render_plain_text_mut(
|
||||
content,
|
||||
mentions,
|
||||
&mut text,
|
||||
&mut highlights,
|
||||
&mut link_ranges,
|
||||
&mut link_urls,
|
||||
persons,
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -61,7 +68,7 @@ impl RenderedText {
|
||||
}
|
||||
|
||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||
let link_color = cx.theme().text_accent;
|
||||
let code_background = cx.theme().elevated_surface_background;
|
||||
|
||||
InteractiveText::new(
|
||||
id,
|
||||
@@ -71,15 +78,35 @@ impl RenderedText {
|
||||
(
|
||||
range.clone(),
|
||||
match highlight {
|
||||
Highlight::Link => HighlightStyle {
|
||||
color: Some(link_color),
|
||||
underline: Some(UnderlineStyle::default()),
|
||||
Highlight::Code => HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::Nostr => HighlightStyle {
|
||||
color: Some(link_color),
|
||||
Highlight::InlineCode(link) => {
|
||||
if *link {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
Highlight::Mention => HighlightStyle {
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::Highlight(highlight) => *highlight,
|
||||
},
|
||||
)
|
||||
}),
|
||||
@@ -87,22 +114,10 @@ impl RenderedText {
|
||||
)
|
||||
.on_click(self.link_ranges.clone(), {
|
||||
let link_urls = self.link_urls.clone();
|
||||
move |ix, window, cx| {
|
||||
let token = link_urls[ix].as_str();
|
||||
|
||||
if let Some(clean_url) = token.strip_prefix("nostr:") {
|
||||
if let Ok(public_key) = PublicKey::parse(clean_url) {
|
||||
window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
|
||||
}
|
||||
} else if is_url(token) {
|
||||
let url = if token.starts_with("http") {
|
||||
token.to_string()
|
||||
} else {
|
||||
format!("https://{token}")
|
||||
};
|
||||
cx.open_url(&url);
|
||||
} else {
|
||||
log::warn!("Unrecognized token {token}")
|
||||
move |ix, _, cx| {
|
||||
let url = &link_urls[ix];
|
||||
if url.starts_with("http") {
|
||||
cx.open_url(url);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -110,214 +125,273 @@ impl RenderedText {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_plain_text_mut(
|
||||
content: &str,
|
||||
block: &str,
|
||||
mut mentions: &[Mention],
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
persons: &Entity<PersonRegistry>,
|
||||
cx: &App,
|
||||
) {
|
||||
// Copy the content directly
|
||||
text.push_str(content);
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||
|
||||
// Collect all URLs
|
||||
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
|
||||
let mut bold_depth = 0;
|
||||
let mut italic_depth = 0;
|
||||
let mut strikethrough_depth = 0;
|
||||
let mut link_url = None;
|
||||
let mut list_stack = Vec::new();
|
||||
|
||||
for link in URL_REGEX.find_iter(content) {
|
||||
let range = link.start()..link.end();
|
||||
let url = link.as_str().to_string();
|
||||
let mut options = Options::all();
|
||||
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
|
||||
|
||||
url_matches.push((range, url));
|
||||
}
|
||||
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
|
||||
let prev_len = text.len();
|
||||
|
||||
// Collect all nostr entities with nostr: prefix
|
||||
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
|
||||
match event {
|
||||
Event::Text(t) => {
|
||||
// Process text with mention replacements
|
||||
let t_str = t.as_ref();
|
||||
let mut last_processed = 0;
|
||||
|
||||
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
|
||||
let range = nostr_match.start()..nostr_match.end();
|
||||
let nostr_uri = nostr_match.as_str().to_string();
|
||||
while let Some(mention) = mentions.first() {
|
||||
if !source_range.contains_inclusive(&mention.range) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if this nostr URI overlaps with any already processed URL
|
||||
if !url_matches
|
||||
.iter()
|
||||
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end)
|
||||
{
|
||||
nostr_matches.push((range, nostr_uri));
|
||||
}
|
||||
}
|
||||
// Calculate positions within the current text
|
||||
let mention_start_in_text = mention.range.start - source_range.start;
|
||||
let mention_end_in_text = mention.range.end - source_range.start;
|
||||
|
||||
// Combine all matches for processing from end to start
|
||||
let mut all_matches = Vec::new();
|
||||
all_matches.extend(url_matches);
|
||||
all_matches.extend(nostr_matches);
|
||||
// Add text before this mention
|
||||
if mention_start_in_text > last_processed {
|
||||
let before_mention = &t_str[last_processed..mention_start_in_text];
|
||||
process_text_segment(
|
||||
before_mention,
|
||||
prev_len + last_processed,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
link_url.clone(),
|
||||
text,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by position (end to start) to avoid changing positions when replacing text
|
||||
all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start));
|
||||
// Process the mention replacement
|
||||
let profile = persons.read(cx).get(&mention.public_key, cx);
|
||||
let replacement_text = format!("@{}", profile.name());
|
||||
|
||||
// Process all matches
|
||||
for (range, entity) in all_matches {
|
||||
// Handle URL token
|
||||
if is_url(&entity) {
|
||||
highlights.push((range.clone(), Highlight::Link));
|
||||
link_ranges.push(range);
|
||||
link_urls.push(entity);
|
||||
continue;
|
||||
};
|
||||
let replacement_start = text.len();
|
||||
text.push_str(&replacement_text);
|
||||
let replacement_end = text.len();
|
||||
|
||||
if let Ok(nip21) = Nip21::parse(&entity) {
|
||||
match nip21 {
|
||||
Nip21::Pubkey(public_key) => {
|
||||
render_pubkey(
|
||||
public_key,
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
cx,
|
||||
);
|
||||
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
||||
|
||||
last_processed = mention_end_in_text;
|
||||
mentions = &mentions[1..];
|
||||
}
|
||||
Nip21::Profile(nip19_profile) => {
|
||||
render_pubkey(
|
||||
nip19_profile.public_key,
|
||||
|
||||
// Add any remaining text after the last mention
|
||||
if last_processed < t_str.len() {
|
||||
let remaining_text = &t_str[last_processed..];
|
||||
process_text_segment(
|
||||
remaining_text,
|
||||
prev_len + last_processed,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
link_url.clone(),
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Nip21::EventId(event_id) => {
|
||||
render_bech32(
|
||||
event_id.to_bech32().unwrap(),
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
Nip21::Event(nip19_event) => {
|
||||
render_bech32(
|
||||
nip19_event.to_bech32().unwrap(),
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
Nip21::Coordinate(nip19_coordinate) => {
|
||||
render_bech32(
|
||||
nip19_coordinate.to_bech32().unwrap(),
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Code(t) => {
|
||||
text.push_str(t.as_ref());
|
||||
let is_link = link_url.is_some();
|
||||
|
||||
if let Some(link_url) = link_url.clone() {
|
||||
link_ranges.push(prev_len..text.len());
|
||||
link_urls.push(link_url);
|
||||
}
|
||||
|
||||
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
|
||||
}
|
||||
Event::Start(tag) => match tag {
|
||||
Tag::Paragraph => new_paragraph(text, &mut list_stack),
|
||||
Tag::Heading { .. } => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
bold_depth += 1;
|
||||
}
|
||||
Tag::CodeBlock(_kind) => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
}
|
||||
Tag::Emphasis => italic_depth += 1,
|
||||
Tag::Strong => bold_depth += 1,
|
||||
Tag::Strikethrough => strikethrough_depth += 1,
|
||||
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
|
||||
Tag::List(number) => {
|
||||
list_stack.push((number, false));
|
||||
}
|
||||
Tag::Item => {
|
||||
let len = list_stack.len();
|
||||
if let Some((list_number, has_content)) = list_stack.last_mut() {
|
||||
*has_content = false;
|
||||
if !text.is_empty() && !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
for _ in 0..len - 1 {
|
||||
text.push_str(" ");
|
||||
}
|
||||
if let Some(number) = list_number {
|
||||
text.push_str(&format!("{}. ", number));
|
||||
*number += 1;
|
||||
*has_content = false;
|
||||
} else {
|
||||
text.push_str("- ");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::End(tag) => match tag {
|
||||
TagEnd::Heading(_) => bold_depth -= 1,
|
||||
TagEnd::Emphasis => italic_depth -= 1,
|
||||
TagEnd::Strong => bold_depth -= 1,
|
||||
TagEnd::Strikethrough => strikethrough_depth -= 1,
|
||||
TagEnd::Link => link_url = None,
|
||||
TagEnd::List(_) => drop(list_stack.pop()),
|
||||
_ => {}
|
||||
},
|
||||
Event::HardBreak => text.push('\n'),
|
||||
Event::SoftBreak => text.push('\n'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a string is a URL
|
||||
fn is_url(s: &str) -> bool {
|
||||
URL_REGEX.is_match(s)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_text_segment(
|
||||
segment: &str,
|
||||
segment_start: usize,
|
||||
bold_depth: i32,
|
||||
italic_depth: i32,
|
||||
strikethrough_depth: i32,
|
||||
link_url: Option<String>,
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
) {
|
||||
// Build the style for this segment
|
||||
let mut style = HighlightStyle::default();
|
||||
if bold_depth > 0 {
|
||||
style.font_weight = Some(FontWeight::BOLD);
|
||||
}
|
||||
if italic_depth > 0 {
|
||||
style.font_style = Some(FontStyle::Italic);
|
||||
}
|
||||
if strikethrough_depth > 0 {
|
||||
style.strikethrough = Some(StrikethroughStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// Format a bech32 entity with ellipsis and last 4 characters
|
||||
fn format_shortened_entity(entity: &str) -> String {
|
||||
let prefix_end = entity.find('1').unwrap_or(0);
|
||||
// Add the text
|
||||
text.push_str(segment);
|
||||
let text_end = text.len();
|
||||
|
||||
if prefix_end > 0 && entity.len() > prefix_end + 5 {
|
||||
let prefix = &entity[0..=prefix_end]; // Include the '1'
|
||||
let suffix = &entity[entity.len() - 4..]; // Last 4 chars
|
||||
if let Some(link_url) = link_url {
|
||||
// Handle as a markdown link
|
||||
link_ranges.push(segment_start..text_end);
|
||||
link_urls.push(link_url);
|
||||
style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
format!("{prefix}...{suffix}")
|
||||
// Add highlight for the entire linked segment
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((segment_start..text_end, Highlight::Highlight(style)));
|
||||
}
|
||||
} else {
|
||||
entity.to_string()
|
||||
}
|
||||
}
|
||||
// Handle link detection within the segment
|
||||
let mut finder = linkify::LinkFinder::new();
|
||||
finder.kinds(&[linkify::LinkKind::Url]);
|
||||
let mut last_link_pos = 0;
|
||||
|
||||
fn render_pubkey(
|
||||
public_key: PublicKey,
|
||||
text: &mut String,
|
||||
range: &Range<usize>,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
cx: &App,
|
||||
) {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
let display_name = format!("@{}", profile.name());
|
||||
for link in finder.links(segment) {
|
||||
let start = link.start();
|
||||
let end = link.end();
|
||||
|
||||
text.replace_range(range.clone(), &display_name);
|
||||
// Add non-link text before this link
|
||||
if start > last_link_pos {
|
||||
let non_link_start = segment_start + last_link_pos;
|
||||
let non_link_end = segment_start + start;
|
||||
|
||||
let new_length = display_name.len();
|
||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
||||
let new_range = range.start..(range.start + new_length);
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((non_link_start..non_link_end, Highlight::Highlight(style)));
|
||||
}
|
||||
}
|
||||
|
||||
highlights.push((new_range.clone(), Highlight::Nostr));
|
||||
link_ranges.push(new_range);
|
||||
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
|
||||
// Add the link
|
||||
let range = (segment_start + start)..(segment_start + end);
|
||||
link_ranges.push(range.clone());
|
||||
link_urls.push(link.as_str().to_string());
|
||||
|
||||
if length_diff != 0 {
|
||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
||||
}
|
||||
}
|
||||
// Apply link styling (underline + existing style)
|
||||
let mut link_style = style;
|
||||
link_style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
fn render_bech32(
|
||||
bech32: String,
|
||||
text: &mut String,
|
||||
range: &Range<usize>,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
) {
|
||||
let njump_url = format!("https://njump.me/{bech32}");
|
||||
let shortened_entity = format_shortened_entity(&bech32);
|
||||
let display_text = format!("https://njump.me/{shortened_entity}");
|
||||
highlights.push((range, Highlight::Highlight(link_style)));
|
||||
|
||||
text.replace_range(range.clone(), &display_text);
|
||||
|
||||
let new_length = display_text.len();
|
||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
||||
let new_range = range.start..(range.start + new_length);
|
||||
|
||||
highlights.push((new_range.clone(), Highlight::Link));
|
||||
link_ranges.push(new_range);
|
||||
link_urls.push(njump_url);
|
||||
|
||||
if length_diff != 0 {
|
||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to adjust ranges when text length changes
|
||||
fn adjust_ranges(
|
||||
highlights: &mut [(Range<usize>, Highlight)],
|
||||
link_ranges: &mut [Range<usize>],
|
||||
position: usize,
|
||||
length_diff: isize,
|
||||
) {
|
||||
// Adjust highlight ranges
|
||||
for (range, _) in highlights.iter_mut() {
|
||||
if range.start > position {
|
||||
range.start = (range.start as isize + length_diff) as usize;
|
||||
range.end = (range.end as isize + length_diff) as usize;
|
||||
last_link_pos = end;
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust link ranges
|
||||
for range in link_ranges.iter_mut() {
|
||||
if range.start > position {
|
||||
range.start = (range.start as isize + length_diff) as usize;
|
||||
range.end = (range.end as isize + length_diff) as usize;
|
||||
// Add any remaining text after the last link
|
||||
if last_link_pos < segment.len() {
|
||||
let remaining_start = segment_start + last_link_pos;
|
||||
let remaining_end = segment_start + segment.len();
|
||||
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((remaining_start..remaining_end, Highlight::Highlight(style)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||
let mut is_subsequent_paragraph_of_list = false;
|
||||
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||
if *has_content {
|
||||
is_subsequent_paragraph_of_list = true;
|
||||
} else {
|
||||
*has_content = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||
text.push_str(" ");
|
||||
}
|
||||
if is_subsequent_paragraph_of_list {
|
||||
text.push_str(" ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,4 @@ log.workspace = true
|
||||
|
||||
dirs = "5.0"
|
||||
qrcode = "0.14.1"
|
||||
bech32 = "0.11.1"
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
pub use debounced_delay::*;
|
||||
pub use display::*;
|
||||
pub use event::*;
|
||||
pub use parser::*;
|
||||
pub use paths::*;
|
||||
pub use range::*;
|
||||
|
||||
mod debounced_delay;
|
||||
mod display;
|
||||
mod event;
|
||||
mod parser;
|
||||
mod paths;
|
||||
mod range;
|
||||
|
||||
210
crates/common/src/parser.rs
Normal file
210
crates/common/src/parser.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
const BECH32_SEPARATOR: u8 = b'1';
|
||||
const SCHEME_WITH_COLON: &str = "nostr:";
|
||||
|
||||
/// Nostr parsed token with its range in the original text
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Token {
|
||||
/// The parsed NIP-21 URI
|
||||
///
|
||||
/// <https://github.com/nostr-protocol/nips/blob/master/21.md>
|
||||
pub value: Nip21,
|
||||
/// The range of this token in the original text
|
||||
pub range: Range<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Match {
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
/// Nostr parser
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct NostrParser;
|
||||
|
||||
impl Default for NostrParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrParser {
|
||||
/// Create new parser
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Parse text
|
||||
pub fn parse<'a>(&self, text: &'a str) -> NostrParserIter<'a> {
|
||||
NostrParserIter::new(text)
|
||||
}
|
||||
}
|
||||
|
||||
struct FindMatches<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> FindMatches<'a> {
|
||||
fn new(text: &'a str) -> Self {
|
||||
Self {
|
||||
bytes: text.as_bytes(),
|
||||
pos: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn try_parse_nostr_uri(&mut self) -> Option<Match> {
|
||||
let start = self.pos;
|
||||
let bytes = self.bytes;
|
||||
let len = bytes.len();
|
||||
|
||||
// Check if we have "nostr:" prefix
|
||||
if len - start < SCHEME_WITH_COLON.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check for "nostr:" prefix (case-insensitive)
|
||||
let scheme_prefix = &bytes[start..start + SCHEME_WITH_COLON.len()];
|
||||
if !scheme_prefix.eq_ignore_ascii_case(SCHEME_WITH_COLON.as_bytes()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip the scheme
|
||||
let pos = start + SCHEME_WITH_COLON.len();
|
||||
|
||||
// Parse bech32 entity
|
||||
let mut has_separator = false;
|
||||
let mut end = pos;
|
||||
|
||||
while end < len {
|
||||
let byte = bytes[end];
|
||||
|
||||
// Check for bech32 separator
|
||||
if byte == BECH32_SEPARATOR && !has_separator {
|
||||
has_separator = true;
|
||||
end += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if character is valid for bech32
|
||||
if !byte.is_ascii_alphanumeric() {
|
||||
break;
|
||||
}
|
||||
|
||||
end += 1;
|
||||
}
|
||||
|
||||
// Must have at least one character after separator
|
||||
if !has_separator || end <= pos + 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Update position
|
||||
self.pos = end;
|
||||
|
||||
Some(Match { start, end })
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for FindMatches<'_> {
|
||||
type Item = Match;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
while self.pos < self.bytes.len() {
|
||||
// Try to parse nostr URI
|
||||
if let Some(mat) = self.try_parse_nostr_uri() {
|
||||
return Some(mat);
|
||||
}
|
||||
|
||||
// Skip one character if no match found
|
||||
self.pos += 1;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
enum HandleMatch {
|
||||
Token(Token),
|
||||
Recursion,
|
||||
}
|
||||
|
||||
pub struct NostrParserIter<'a> {
|
||||
/// The original text
|
||||
text: &'a str,
|
||||
/// Matches found
|
||||
matches: FindMatches<'a>,
|
||||
/// A pending match
|
||||
pending_match: Option<Match>,
|
||||
/// Last match end index
|
||||
last_match_end: usize,
|
||||
}
|
||||
|
||||
impl<'a> NostrParserIter<'a> {
|
||||
fn new(text: &'a str) -> Self {
|
||||
Self {
|
||||
text,
|
||||
matches: FindMatches::new(text),
|
||||
pending_match: None,
|
||||
last_match_end: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_match(&mut self, mat: Match) -> HandleMatch {
|
||||
// Update last match end
|
||||
self.last_match_end = mat.end;
|
||||
|
||||
// Extract the matched string
|
||||
let data: &str = &self.text[mat.start..mat.end];
|
||||
|
||||
// Parse NIP-21 URI
|
||||
match Nip21::parse(data) {
|
||||
Ok(uri) => HandleMatch::Token(Token {
|
||||
value: uri,
|
||||
range: mat.start..mat.end,
|
||||
}),
|
||||
// If the nostr URI parsing is invalid, skip it
|
||||
Err(_) => HandleMatch::Recursion,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for NostrParserIter<'a> {
|
||||
type Item = Token;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
// Handle a pending match
|
||||
if let Some(pending_match) = self.pending_match.take() {
|
||||
return match self.handle_match(pending_match) {
|
||||
HandleMatch::Token(token) => Some(token),
|
||||
HandleMatch::Recursion => self.next(),
|
||||
};
|
||||
}
|
||||
|
||||
match self.matches.next() {
|
||||
Some(mat) => {
|
||||
// Skip any text before this match
|
||||
if mat.start > self.last_match_end {
|
||||
// Update pending match
|
||||
// This will be handled at next iteration, in `handle_match` method.
|
||||
self.pending_match = Some(mat);
|
||||
|
||||
// Skip the text before the match
|
||||
self.last_match_end = mat.start;
|
||||
return self.next();
|
||||
}
|
||||
|
||||
// Handle match
|
||||
match self.handle_match(mat) {
|
||||
HandleMatch::Token(token) => Some(token),
|
||||
HandleMatch::Recursion => self.next(),
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
45
crates/common/src/range.rs
Normal file
45
crates/common/src/range.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use std::cmp::{self};
|
||||
use std::ops::{Range, RangeInclusive};
|
||||
|
||||
pub trait RangeExt<T> {
|
||||
fn sorted(&self) -> Self;
|
||||
fn to_inclusive(&self) -> RangeInclusive<T>;
|
||||
fn overlaps(&self, other: &Range<T>) -> bool;
|
||||
fn contains_inclusive(&self, other: &Range<T>) -> bool;
|
||||
}
|
||||
|
||||
impl<T: Ord + Clone> RangeExt<T> for Range<T> {
|
||||
fn sorted(&self) -> Self {
|
||||
cmp::min(&self.start, &self.end).clone()..cmp::max(&self.start, &self.end).clone()
|
||||
}
|
||||
|
||||
fn to_inclusive(&self) -> RangeInclusive<T> {
|
||||
self.start.clone()..=self.end.clone()
|
||||
}
|
||||
|
||||
fn overlaps(&self, other: &Range<T>) -> bool {
|
||||
self.start < other.end && other.start < self.end
|
||||
}
|
||||
|
||||
fn contains_inclusive(&self, other: &Range<T>) -> bool {
|
||||
self.start <= other.start && other.end <= self.end
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Ord + Clone> RangeExt<T> for RangeInclusive<T> {
|
||||
fn sorted(&self) -> Self {
|
||||
cmp::min(self.start(), self.end()).clone()..=cmp::max(self.start(), self.end()).clone()
|
||||
}
|
||||
|
||||
fn to_inclusive(&self) -> RangeInclusive<T> {
|
||||
self.clone()
|
||||
}
|
||||
|
||||
fn overlaps(&self, other: &Range<T>) -> bool {
|
||||
self.start() < &other.end && &other.start <= self.end()
|
||||
}
|
||||
|
||||
fn contains_inclusive(&self, other: &Range<T>) -> bool {
|
||||
self.start() <= &other.start && &other.end <= self.end()
|
||||
}
|
||||
}
|
||||
@@ -64,4 +64,8 @@ oneshot.workspace = true
|
||||
webbrowser.workspace = true
|
||||
|
||||
indexset = "0.12.3"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Temporary workaround https://github.com/zed-industries/zed/issues/47168
|
||||
core-text = "=21.0.0"
|
||||
|
||||
256
crates/coop/src/dialogs/accounts.rs
Normal file
256
crates/coop/src/dialogs/accounts.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use state::{NostrRegistry, SignerEvent};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, WindowExtension};
|
||||
|
||||
use crate::dialogs::connect::ConnectSigner;
|
||||
use crate::dialogs::import::ImportKey;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<AccountSelector> {
|
||||
cx.new(|cx| AccountSelector::new(window, cx))
|
||||
}
|
||||
|
||||
/// Account selector
|
||||
pub struct AccountSelector {
|
||||
/// Public key currently being chosen for login
|
||||
logging_in: Entity<Option<PublicKey>>,
|
||||
|
||||
/// The error message displayed when an error occurs.
|
||||
error: Entity<Option<SharedString>>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
/// Subscription to the signer events
|
||||
_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl AccountSelector {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let logging_in = cx.new(|_| None);
|
||||
let error = cx.new(|_| None);
|
||||
|
||||
// Subscribe to the signer events
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, window, cx| {
|
||||
match event {
|
||||
SignerEvent::Set => {
|
||||
window.close_all_modals(cx);
|
||||
window.refresh();
|
||||
}
|
||||
SignerEvent::Error(e) => {
|
||||
this.set_error(e.to_string(), cx);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
Self {
|
||||
logging_in,
|
||||
error,
|
||||
tasks: vec![],
|
||||
_subscription: Some(subscription),
|
||||
}
|
||||
}
|
||||
|
||||
fn logging_in(&self, public_key: &PublicKey, cx: &App) -> bool {
|
||||
self.logging_in.read(cx) == &Some(*public_key)
|
||||
}
|
||||
|
||||
fn set_logging_in(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
self.logging_in.update(cx, |this, cx| {
|
||||
*this = Some(public_key);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn set_error<T>(&mut self, error: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
self.error.update(cx, |this, cx| {
|
||||
*this = Some(error.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.logging_in.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn login(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let task = nostr.read(cx).get_signer(&public_key, cx);
|
||||
|
||||
// Mark the public key as being logged in
|
||||
self.set_logging_in(public_key, cx);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(signer) => {
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(e.to_string(), cx);
|
||||
})?;
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn remove(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.remove_signer(&public_key, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let import = cx.new(|cx| ImportKey::new(window, cx));
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.width(px(460.))
|
||||
.title("Import a Secret Key or Bunker Connection")
|
||||
.show_close(true)
|
||||
.pb_2()
|
||||
.child(import.clone())
|
||||
});
|
||||
}
|
||||
|
||||
fn open_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let connect = cx.new(|cx| ConnectSigner::new(window, cx));
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.width(px(460.))
|
||||
.title("Scan QR Code to Connect")
|
||||
.show_close(true)
|
||||
.pb_2()
|
||||
.child(connect.clone())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AccountSelector {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let npubs = nostr.read(cx).npubs();
|
||||
let loading = self.logging_in.read(cx).is_some();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.gap_2()
|
||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().danger_active)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
.children({
|
||||
let mut items = vec![];
|
||||
|
||||
for (ix, public_key) in npubs.read(cx).iter().enumerate() {
|
||||
let profile = persons.read(cx).get(public_key, cx);
|
||||
let logging_in = self.logging_in(public_key, cx);
|
||||
|
||||
items.push(
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.group("")
|
||||
.px_2()
|
||||
.h_10()
|
||||
.justify_between()
|
||||
.w_full()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().ghost_element_background)
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Avatar::new(profile.avatar()).small())
|
||||
.child(div().text_sm().child(profile.name())),
|
||||
)
|
||||
.when(logging_in, |this| this.child(Indicator::new().small()))
|
||||
.when(!logging_in, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.child(
|
||||
Button::new(format!("del-{ix}"))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(logging_in)
|
||||
.on_click(cx.listener({
|
||||
let public_key = *public_key;
|
||||
move |this, _ev, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
this.remove(public_key, cx);
|
||||
}
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(!logging_in, |this| {
|
||||
let public_key = *public_key;
|
||||
this.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.login(public_key, window, cx);
|
||||
}))
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
.child(div().w_full().h_px().bg(cx.theme().border_variant))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.justify_end()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("input")
|
||||
.icon(Icon::new(IconName::Usb))
|
||||
.label("Import")
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(loading)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.open_import(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("qr")
|
||||
.icon(Icon::new(IconName::Scan))
|
||||
.label("Scan QR to connect")
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(loading)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.open_connect(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
115
crates/coop/src/dialogs/connect.rs
Normal file
115
crates/coop/src/dialogs/connect.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::TextUtils;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, px, AppContext, Context, Entity, Image, IntoElement, ParentElement, Render,
|
||||
SharedString, Styled, Subscription, Window,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use state::{
|
||||
CoopAuthUrlHandler, NostrRegistry, SignerEvent, CLIENT_NAME, NOSTR_CONNECT_RELAY,
|
||||
NOSTR_CONNECT_TIMEOUT,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::v_flex;
|
||||
|
||||
pub struct ConnectSigner {
|
||||
/// QR Code
|
||||
qr_code: Option<Arc<Image>>,
|
||||
|
||||
/// Error message
|
||||
error: Entity<Option<SharedString>>,
|
||||
|
||||
/// Subscription to the signer event
|
||||
_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl ConnectSigner {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let error = cx.new(|_| None);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let app_keys = nostr.read(cx).app_keys.clone();
|
||||
|
||||
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
|
||||
|
||||
// Generate the nostr connect uri
|
||||
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
|
||||
|
||||
// Generate the nostr connect
|
||||
let mut signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap();
|
||||
|
||||
// Handle the auth request
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
// Generate a QR code for quick connection
|
||||
let qr_code = uri.to_string().to_qr();
|
||||
|
||||
// Set signer in the background
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.add_nip46_signer(&signer, cx);
|
||||
});
|
||||
|
||||
// Subscribe to the signer event
|
||||
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
|
||||
if let SignerEvent::Error(e) = event {
|
||||
this.set_error(e, cx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
qr_code,
|
||||
error,
|
||||
_subscription: Some(subscription),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
||||
where
|
||||
S: Into<SharedString>,
|
||||
{
|
||||
self.error.update(cx, |this, cx| {
|
||||
*this = Some(message.into());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ConnectSigner {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const MSG: &str = "Scan with any Nostr Connect-compatible app to connect";
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_4()
|
||||
.when_some(self.qr_code.as_ref(), |this, qr| {
|
||||
this.child(
|
||||
img(qr.clone())
|
||||
.size(px(256.))
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border),
|
||||
)
|
||||
})
|
||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().danger_active)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(MSG)),
|
||||
)
|
||||
}
|
||||
}
|
||||
301
crates/coop/src/dialogs/import.rs
Normal file
301
crates/coop/src/dialogs/import.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Subscription, Task, Window,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{CoopAuthUrlHandler, NostrRegistry, SignerEvent};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::{v_flex, Disableable};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportKey {
|
||||
/// Secret key input
|
||||
key_input: Entity<InputState>,
|
||||
|
||||
/// Password input (if required)
|
||||
pass_input: Entity<InputState>,
|
||||
|
||||
/// Error message
|
||||
error: Entity<Option<SharedString>>,
|
||||
|
||||
/// Countdown timer for nostr connect
|
||||
countdown: Entity<Option<u64>>,
|
||||
|
||||
/// Whether the user is currently loading
|
||||
loading: bool,
|
||||
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl ImportKey {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
let error = cx.new(|_| None);
|
||||
let countdown = cx.new(|_| None);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to key input events and process login when the user presses enter
|
||||
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.login(window, cx);
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to the nostr signer event
|
||||
cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
|
||||
if let SignerEvent::Error(e) = event {
|
||||
this.set_error(e, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
key_input,
|
||||
pass_input,
|
||||
error,
|
||||
countdown,
|
||||
loading: false,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.loading {
|
||||
return;
|
||||
};
|
||||
// Prevent duplicate login requests
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let value = self.key_input.read(cx).value();
|
||||
let password = self.pass_input.read(cx).value();
|
||||
|
||||
if value.starts_with("bunker://") {
|
||||
self.bunker(&value, window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if value.starts_with("ncryptsec1") {
|
||||
self.ncryptsec(value, password, window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(secret) = SecretKey::parse(&value) {
|
||||
let keys = Keys::new(secret);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
// Update the signer
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.add_key_signer(&keys, cx);
|
||||
});
|
||||
} else {
|
||||
self.set_error("Invalid key", cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(uri) = NostrConnectUri::parse(content) else {
|
||||
self.set_error("Bunker is not valid", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let app_keys = nostr.read(cx).app_keys.clone();
|
||||
let timeout = Duration::from_secs(30);
|
||||
|
||||
// Construct the nostr connect signer
|
||||
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
// Set signer in the background
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.add_nip46_signer(&signer, cx);
|
||||
});
|
||||
|
||||
// Start countdown
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
for i in (0..=30).rev() {
|
||||
if i == 0 {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_countdown(None, cx);
|
||||
})?;
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_countdown(Some(i), cx);
|
||||
})?;
|
||||
}
|
||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn ncryptsec<S>(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
S: Into<String>,
|
||||
{
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let content: String = content.into();
|
||||
let password: String = pwd.into();
|
||||
|
||||
if password.is_empty() {
|
||||
self.set_error("Password is required", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(enc) = EncryptedSecretKey::from_bech32(&content) else {
|
||||
self.set_error("Secret Key is invalid", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
// Decrypt in the background to ensure it doesn't block the UI
|
||||
let task = cx.background_spawn(async move {
|
||||
if let Ok(content) = enc.decrypt(&password) {
|
||||
Ok(Keys::new(content))
|
||||
} else {
|
||||
Err(anyhow!("Invalid password"))
|
||||
}
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(keys) => {
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.add_key_signer(&keys, cx);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(e.to_string(), cx);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
||||
where
|
||||
S: Into<SharedString>,
|
||||
{
|
||||
// Reset the log in state
|
||||
self.set_loading(false, cx);
|
||||
|
||||
// Reset the countdown
|
||||
self.set_countdown(None, cx);
|
||||
|
||||
// Update error message
|
||||
self.error.update(cx, |this, cx| {
|
||||
*this = Some(message.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Clear the error message after 3 secs
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.error.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
});
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
|
||||
self.countdown.update(cx, |this, cx| {
|
||||
*this = i;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ImportKey {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.p_4()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("nsec or bunker://")
|
||||
.child(TextInput::new(&self.key_input)),
|
||||
)
|
||||
.when(
|
||||
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|
||||
|this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Password:")
|
||||
.child(TextInput::new(&self.pass_input)),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label("Continue")
|
||||
.primary()
|
||||
.loading(self.loading)
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.login(window, cx);
|
||||
})),
|
||||
)
|
||||
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(format!(
|
||||
"Approve connection request from your signer in {} seconds",
|
||||
i
|
||||
))),
|
||||
)
|
||||
})
|
||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().danger_active)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
pub mod accounts;
|
||||
pub mod screening;
|
||||
pub mod settings;
|
||||
|
||||
mod connect;
|
||||
mod import;
|
||||
|
||||
@@ -254,7 +254,7 @@ impl Screening {
|
||||
let total = contacts.len();
|
||||
|
||||
this.title(SharedString::from("Mutual contacts")).child(
|
||||
v_flex().gap_1().pb_4().child(
|
||||
v_flex().gap_1().pb_2().child(
|
||||
uniform_list("contacts", total, move |range, _window, cx| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let mut items = Vec::with_capacity(total);
|
||||
@@ -356,9 +356,9 @@ impl Render for Screening {
|
||||
.child(
|
||||
Button::new("report")
|
||||
.tooltip("Report as a scam or impostor")
|
||||
.icon(IconName::Boom)
|
||||
.icon(IconName::Warning)
|
||||
.small()
|
||||
.danger()
|
||||
.warning()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.report(window, cx);
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::TextUtils;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, px, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString, Styled, Task,
|
||||
Window,
|
||||
};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::dock_area::ClosePanel;
|
||||
use ui::notification::Notification;
|
||||
use ui::{v_flex, StyledExt, WindowExtension};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ConnectPanel> {
|
||||
cx.new(|cx| ConnectPanel::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct ConnectPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// QR Code
|
||||
qr_code: Option<Arc<Image>>,
|
||||
|
||||
/// Background tasks
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl ConnectPanel {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let weak_state = nostr.downgrade();
|
||||
let (signer, uri) = nostr.read(cx).client_connect(None);
|
||||
|
||||
// Generate a QR code for quick connection
|
||||
let qr_code = uri.to_string().to_qr();
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Wait for nostr connect
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
let result = signer.bunker_uri().await;
|
||||
|
||||
weak_state
|
||||
.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(uri) => {
|
||||
this.persist_bunker(uri, cx);
|
||||
this.set_signer(signer, true, cx);
|
||||
// Close the current panel after setting the signer
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Nostr Connect".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
qr_code,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for ConnectPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for ConnectPanel {}
|
||||
|
||||
impl Focusable for ConnectPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ConnectPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.gap_10()
|
||||
.child(
|
||||
v_flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from("Continue with Nostr Connect")),
|
||||
)
|
||||
.child(div().text_sm().text_color(cx.theme().text_muted).child(
|
||||
SharedString::from("Use Nostr Connect apps to scan the code"),
|
||||
)),
|
||||
)
|
||||
.when_some(self.qr_code.as_ref(), |this, qr| {
|
||||
this.child(
|
||||
img(qr.clone())
|
||||
.size(px(256.))
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
use anyhow::Error;
|
||||
use device::DeviceRegistry;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{shorten_pubkey, PersonRegistry};
|
||||
use state::Announcement;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::notification::Notification;
|
||||
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
|
||||
|
||||
const MSG: &str =
|
||||
"Encryption Key is a special key that used to encrypt and decrypt your messages. \
|
||||
Your identity is completely decoupled from all encryption processes to protect your privacy.";
|
||||
|
||||
const NOTICE: &str = "By resetting your encryption key, you will lose access to \
|
||||
all your encrypted messages before. This action cannot be undone.";
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<EncryptionPanel> {
|
||||
cx.new(|cx| EncryptionPanel::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EncryptionPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// User's public key
|
||||
public_key: PublicKey,
|
||||
|
||||
/// Whether the panel is loading
|
||||
loading: bool,
|
||||
|
||||
/// Tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl EncryptionPanel {
|
||||
fn new(public_key: PublicKey, _window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
name: "Encryption".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
public_key,
|
||||
loading: false,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let task = device.read(cx).approve(event, cx);
|
||||
let id = event.id;
|
||||
|
||||
// Update loading status
|
||||
self.set_loading(true, cx);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
// Reset loading status
|
||||
this.set_loading(false, cx);
|
||||
|
||||
// Remove request
|
||||
device.update(cx, |this, cx| {
|
||||
this.remove_request(&id, cx);
|
||||
});
|
||||
|
||||
window.push_notification("Approved", cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_loading(false, cx);
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn render_requests(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
|
||||
const TITLE: &str = "You've requested for the Encryption Key from:";
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let requests = device.read(cx).requests.clone();
|
||||
let mut items = Vec::new();
|
||||
|
||||
for event in requests.into_iter() {
|
||||
let request = Announcement::from(&event);
|
||||
let client_name = request.client_name();
|
||||
let target = request.public_key();
|
||||
|
||||
items.push(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(SharedString::from(TITLE))
|
||||
.child(
|
||||
v_flex()
|
||||
.h_12()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().warning_background)
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.child(client_name.clone()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_7()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(SharedString::from(target.to_hex())),
|
||||
)
|
||||
.child(
|
||||
h_flex().justify_end().gap_2().child(
|
||||
Button::new("approve")
|
||||
.label("Approve")
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(self.loading)
|
||||
.loading(self.loading)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.approve(&event, window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for EncryptionPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for EncryptionPanel {}
|
||||
|
||||
impl Focusable for EncryptionPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for EncryptionPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let state = device.read(cx).state();
|
||||
let has_requests = device.read(cx).has_requests();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&self.public_key, cx);
|
||||
|
||||
let Some(announcement) = profile.announcement() else {
|
||||
return div();
|
||||
};
|
||||
|
||||
let pubkey = SharedString::from(shorten_pubkey(announcement.public_key(), 16));
|
||||
let client_name = announcement.client_name();
|
||||
|
||||
v_flex()
|
||||
.p_3()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(MSG)),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Device Name:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_12()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(client_name.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Encryption Public Key:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_7()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(pubkey),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(has_requests, |this| {
|
||||
this.child(divider(cx)).child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Requests:")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.children(self.render_requests(cx)),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(divider(cx))
|
||||
.when(state.requesting(), |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_accent)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(SharedString::from(
|
||||
"Please open other device and approve the request",
|
||||
)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("reset")
|
||||
.icon(IconName::Reset)
|
||||
.label("Reset")
|
||||
.warning()
|
||||
.small()
|
||||
.font_semibold(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_size(px(10.))
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(NOTICE)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use ui::dock_area::dock::DockPlacement;
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
use crate::panels::{connect, import, messaging_relays, profile, relay_list};
|
||||
use crate::panels::{messaging_relays, profile, relay_list};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
||||
@@ -86,10 +86,7 @@ impl Render for GreeterPanel {
|
||||
let nip17 = chat.read(cx).state(cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let nip65 = nostr.read(cx).relay_list_state();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let owned = signer.owned();
|
||||
let nip65 = nostr.read(cx).relay_list_state.clone();
|
||||
|
||||
let required_actions =
|
||||
nip65 == RelayState::NotConfigured || nip17 == InboxState::RelayNotAvailable;
|
||||
@@ -191,60 +188,6 @@ impl Render for GreeterPanel {
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(!owned, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Use your own identity"))
|
||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("connect")
|
||||
.icon(Icon::new(IconName::Door))
|
||||
.label("Connect account via Nostr Connect")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
connect::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("import")
|
||||
.icon(Icon::new(IconName::Usb))
|
||||
.label("Import a secret key or bunker")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
import::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{CoopAuthUrlHandler, NostrRegistry};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::dock_area::ClosePanel;
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::notification::Notification;
|
||||
use ui::{v_flex, Disableable, StyledExt, WindowExtension};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ImportPanel> {
|
||||
cx.new(|cx| ImportPanel::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Secret key input
|
||||
key_input: Entity<InputState>,
|
||||
|
||||
/// Password input (if required)
|
||||
pass_input: Entity<InputState>,
|
||||
|
||||
/// Error message
|
||||
error: Entity<Option<SharedString>>,
|
||||
|
||||
/// Countdown timer for nostr connect
|
||||
countdown: Entity<Option<u64>>,
|
||||
|
||||
/// Whether the user is currently logging in
|
||||
logging_in: bool,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl ImportPanel {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
|
||||
let error = cx.new(|_| None);
|
||||
let countdown = cx.new(|_| None);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to key input events and process login when the user presses enter
|
||||
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.login(window, cx);
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
key_input,
|
||||
pass_input,
|
||||
error,
|
||||
countdown,
|
||||
name: "Import".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
logging_in: false,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.logging_in {
|
||||
return;
|
||||
};
|
||||
// Prevent duplicate login requests
|
||||
self.set_logging_in(true, cx);
|
||||
|
||||
let value = self.key_input.read(cx).value();
|
||||
let password = self.pass_input.read(cx).value();
|
||||
|
||||
if value.starts_with("bunker://") {
|
||||
self.login_with_bunker(&value, window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if value.starts_with("ncryptsec1") {
|
||||
self.login_with_password(&value, &password, window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(secret) = SecretKey::parse(&value) {
|
||||
let keys = Keys::new(secret);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
// Update the signer
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.set_signer(keys, true, cx);
|
||||
});
|
||||
// Close the current panel after setting the signer
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
} else {
|
||||
self.set_error("Invalid", cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(uri) = NostrConnectUri::parse(content) else {
|
||||
self.set_error("Bunker is not valid", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let weak_state = nostr.downgrade();
|
||||
|
||||
let app_keys = nostr.read(cx).app_keys();
|
||||
let timeout = Duration::from_secs(30);
|
||||
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
// Start countdown
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
for i in (0..=30).rev() {
|
||||
if i == 0 {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_countdown(None, cx);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_countdown(Some(i), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle connection
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
let result = signer.bunker_uri().await;
|
||||
|
||||
weak_state
|
||||
.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(uri) => {
|
||||
this.persist_bunker(uri, cx);
|
||||
this.set_signer(signer, true, cx);
|
||||
// Close the current panel after setting the signer
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn login_with_password(
|
||||
&mut self,
|
||||
content: &str,
|
||||
pwd: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if pwd.is_empty() {
|
||||
self.set_error("Password is required", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(enc) = EncryptedSecretKey::from_bech32(content) else {
|
||||
self.set_error("Secret Key is invalid", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let password = pwd.to_owned();
|
||||
|
||||
// Decrypt in the background to ensure it doesn't block the UI
|
||||
let task = cx.background_spawn(async move {
|
||||
if let Ok(content) = enc.decrypt(&password) {
|
||||
Ok(Keys::new(content))
|
||||
} else {
|
||||
Err(anyhow!("Invalid password"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(keys) => {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
// Update the signer
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.set_signer(keys, true, cx);
|
||||
});
|
||||
// Close the current panel after setting the signer
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
}
|
||||
Err(e) => {
|
||||
this.set_error(e.to_string(), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
||||
where
|
||||
S: Into<SharedString>,
|
||||
{
|
||||
// Reset the log in state
|
||||
self.set_logging_in(false, cx);
|
||||
|
||||
// Reset the countdown
|
||||
self.set_countdown(None, cx);
|
||||
|
||||
// Update error message
|
||||
self.error.update(cx, |this, cx| {
|
||||
*this = Some(message.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Clear the error message after 3 secs
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.error.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.logging_in = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
|
||||
self.countdown.update(cx, |this, cx| {
|
||||
*this = i;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for ImportPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for ImportPanel {}
|
||||
|
||||
impl Focusable for ImportPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ImportPanel {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const SECRET_WARN: &str = "* Coop doesn't store your secret key. \
|
||||
It will be cleared when you close the app. \
|
||||
To persist your identity, please connect via Nostr Connect.";
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.gap_10()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from("Import a Secret Key or Bunker")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.w_112()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("nsec or bunker://")
|
||||
.child(TextInput::new(&self.key_input)),
|
||||
)
|
||||
.when(
|
||||
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|
||||
|this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Password:")
|
||||
.child(TextInput::new(&self.pass_input)),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label("Continue")
|
||||
.primary()
|
||||
.loading(self.logging_in)
|
||||
.disabled(self.logging_in)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.login(window, cx);
|
||||
})),
|
||||
)
|
||||
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(format!(
|
||||
"Approve connection request from your signer in {} seconds",
|
||||
i
|
||||
))),
|
||||
)
|
||||
})
|
||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.mt_2()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(SECRET_WARN)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
pub mod backup;
|
||||
pub mod connect;
|
||||
pub mod contact_list;
|
||||
pub mod encryption_key;
|
||||
pub mod greeter;
|
||||
pub mod import;
|
||||
pub mod messaging_relays;
|
||||
pub mod profile;
|
||||
pub mod relay_list;
|
||||
|
||||
@@ -8,22 +8,22 @@ use common::{DebouncedDelay, RenderedTimestamp};
|
||||
use entry::RoomEntry;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription,
|
||||
Task, UniformListScrollHandle, Window,
|
||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||
ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription, Task,
|
||||
UniformListScrollHandle, Window, div, uniform_list,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrRegistry, FIND_DELAY};
|
||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{FIND_DELAY, NostrRegistry};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::notification::Notification;
|
||||
use ui::scroll::Scrollbar;
|
||||
use ui::{h_flex, v_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension};
|
||||
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
mod entry;
|
||||
|
||||
@@ -585,10 +585,11 @@ impl Render for Sidebar {
|
||||
)
|
||||
.when(!show_find_panel && !loading && total_rooms == 0, |this| {
|
||||
this.child(
|
||||
div().px_2().child(
|
||||
div().w(SIDEBAR_WIDTH).px_2().child(
|
||||
v_flex()
|
||||
.p_3()
|
||||
.h_24()
|
||||
.w_full()
|
||||
.border_2()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border_variant)
|
||||
@@ -612,11 +613,9 @@ impl Render for Sidebar {
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.h_full()
|
||||
.px_1p5()
|
||||
.gap_1()
|
||||
.size_full()
|
||||
.flex_1()
|
||||
.overflow_y_hidden()
|
||||
.gap_1()
|
||||
.when(show_find_panel, |this| {
|
||||
this.gap_3()
|
||||
.when_some(self.find_results.read(cx).as_ref(), |this, results| {
|
||||
@@ -687,7 +686,8 @@ impl Render for Sidebar {
|
||||
)
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.flex_1()
|
||||
.h_full(),
|
||||
.h_full()
|
||||
.px_2(),
|
||||
)
|
||||
.child(Scrollbar::vertical(&self.scroll_handle))
|
||||
}),
|
||||
|
||||
@@ -2,16 +2,17 @@ use std::sync::Arc;
|
||||
|
||||
use ::settings::AppSettings;
|
||||
use chat::{ChatEvent, ChatRegistry, InboxState};
|
||||
use device::DeviceRegistry;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Window,
|
||||
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, Styled, Subscription, Window, div, px,
|
||||
};
|
||||
use person::PersonRegistry;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrRegistry, RelayState};
|
||||
use theme::{ActiveTheme, Theme, ThemeRegistry, SIDEBAR_WIDTH};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, RelayState, SignerEvent};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||
use title_bar::TitleBar;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
@@ -19,14 +20,19 @@ use ui::dock_area::dock::DockPlacement;
|
||||
use ui::dock_area::panel::PanelView;
|
||||
use ui::dock_area::{ClosePanel, DockArea, DockItem};
|
||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||
use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension};
|
||||
use ui::notification::Notification;
|
||||
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::dialogs::settings;
|
||||
use crate::panels::{
|
||||
backup, contact_list, encryption_key, greeter, messaging_relays, profile, relay_list,
|
||||
};
|
||||
use crate::dialogs::{accounts, settings};
|
||||
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list};
|
||||
use crate::sidebar;
|
||||
|
||||
const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \
|
||||
Your identity is completely decoupled from all encryption processes to protect your privacy.";
|
||||
|
||||
const ENC_WARN: &str = "By resetting your encryption key, you will lose access to \
|
||||
all your encrypted messages before. This action cannot be undone.";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
|
||||
cx.new(|cx| Workspace::new(window, cx))
|
||||
}
|
||||
@@ -35,13 +41,15 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
|
||||
#[action(namespace = workspace, no_json)]
|
||||
enum Command {
|
||||
ToggleTheme,
|
||||
ToggleAccount,
|
||||
|
||||
RefreshEncryption,
|
||||
RefreshRelayList,
|
||||
RefreshMessagingRelays,
|
||||
ResetEncryption,
|
||||
|
||||
ShowRelayList,
|
||||
ShowMessaging,
|
||||
ShowEncryption,
|
||||
ShowProfile,
|
||||
ShowSettings,
|
||||
ShowBackup,
|
||||
@@ -56,11 +64,13 @@ pub struct Workspace {
|
||||
dock: Entity<DockArea>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let npubs = nostr.read(cx).npubs();
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let titlebar = cx.new(|_| TitleBar::new());
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||
@@ -74,6 +84,24 @@ impl Workspace {
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the npubs entity
|
||||
cx.observe_in(&npubs, window, move |this, npubs, window, cx| {
|
||||
if !npubs.read(cx).is_empty() {
|
||||
this.account_selector(window, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to the signer events
|
||||
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| {
|
||||
if let SignerEvent::Set = event {
|
||||
this.set_center_layout(window, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe all events emitted by the chat registry
|
||||
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
||||
@@ -113,12 +141,12 @@ impl Workspace {
|
||||
let ids = this.panel_ids(cx);
|
||||
|
||||
chat.update(cx, |this, cx| {
|
||||
this.refresh_rooms(ids, cx);
|
||||
this.refresh_rooms(&ids, cx);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Set the default layout for app's dock
|
||||
// Set the layout at the end of cycle
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.set_layout(window, cx);
|
||||
});
|
||||
@@ -147,49 +175,40 @@ impl Workspace {
|
||||
}
|
||||
|
||||
/// Get all panel ids
|
||||
fn panel_ids(&self, cx: &App) -> Option<Vec<u64>> {
|
||||
let ids: Vec<u64> = self
|
||||
.dock
|
||||
fn panel_ids(&self, cx: &App) -> Vec<u64> {
|
||||
self.dock
|
||||
.read(cx)
|
||||
.items
|
||||
.panel_ids(cx)
|
||||
.into_iter()
|
||||
.filter_map(|panel| panel.parse::<u64>().ok())
|
||||
.collect();
|
||||
|
||||
Some(ids)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Set the dock layout
|
||||
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let weak_dock = self.dock.downgrade();
|
||||
|
||||
// Sidebar
|
||||
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
||||
|
||||
// Main workspace
|
||||
let center = DockItem::split_with_sizes(
|
||||
Axis::Vertical,
|
||||
vec![DockItem::tabs(
|
||||
vec![Arc::new(greeter::init(window, cx))],
|
||||
None,
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
)],
|
||||
vec![None],
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
// Update the dock layout
|
||||
// Update the dock layout with sidebar on the left
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Set the center dock layout
|
||||
fn set_center_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let dock = self.dock.downgrade();
|
||||
let greeeter = Arc::new(greeter::init(window, cx));
|
||||
let tabs = DockItem::tabs(vec![greeeter], None, &dock, window, cx);
|
||||
let center = DockItem::split(Axis::Vertical, vec![tabs], &dock, window, cx);
|
||||
|
||||
// Update the layout with center dock
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle command events
|
||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match command {
|
||||
Command::ShowSettings => {
|
||||
@@ -198,7 +217,7 @@ impl Workspace {
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.width(px(520.))
|
||||
.show_close(true)
|
||||
.pb_4()
|
||||
.pb_2()
|
||||
.title("Preferences")
|
||||
.child(view.clone())
|
||||
});
|
||||
@@ -238,21 +257,6 @@ impl Workspace {
|
||||
);
|
||||
});
|
||||
}
|
||||
Command::ShowEncryption => {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
if let Some(public_key) = signer.public_key() {
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.add_panel(
|
||||
Arc::new(encryption_key::init(public_key, window, cx)),
|
||||
DockPlacement::Right,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Command::ShowMessaging => {
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.add_panel(
|
||||
@@ -273,12 +277,21 @@ impl Workspace {
|
||||
);
|
||||
});
|
||||
}
|
||||
Command::RefreshEncryption => {
|
||||
let device = DeviceRegistry::global(cx);
|
||||
device.update(cx, |this, cx| {
|
||||
this.get_announcement(cx);
|
||||
});
|
||||
}
|
||||
Command::RefreshRelayList => {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.ensure_relay_list(cx);
|
||||
});
|
||||
}
|
||||
Command::ResetEncryption => {
|
||||
self.confirm_reset_encryption(window, cx);
|
||||
}
|
||||
Command::RefreshMessagingRelays => {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
chat.update(cx, |this, cx| {
|
||||
@@ -288,9 +301,74 @@ impl Workspace {
|
||||
Command::ToggleTheme => {
|
||||
self.theme_selector(window, cx);
|
||||
}
|
||||
Command::ToggleAccount => {
|
||||
self.account_selector(window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_modal(cx, |this, _window, cx| {
|
||||
this.confirm()
|
||||
.show_close(true)
|
||||
.title("Reset Encryption Keys")
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::from(ENC_MSG))
|
||||
.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_color(cx.theme().warning_active)
|
||||
.child(SharedString::from(ENC_WARN)),
|
||||
),
|
||||
)
|
||||
.on_ok(move |_ev, window, cx| {
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let task = device.read(cx).create_encryption(cx);
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
let result = task.await;
|
||||
|
||||
cx.update(|window, cx| match result {
|
||||
Ok(keys) => {
|
||||
device.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
this.listen_request(cx);
|
||||
});
|
||||
window.close_modal(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window
|
||||
.push_notification(Notification::error(e.to_string()), cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
|
||||
// false to keep modal open
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn account_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let accounts = accounts::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.width(px(520.))
|
||||
.title("Continue with")
|
||||
.show_close(false)
|
||||
.keyboard(false)
|
||||
.overlay_closable(false)
|
||||
.pb_2()
|
||||
.child(accounts.clone())
|
||||
});
|
||||
}
|
||||
|
||||
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
let registry = ThemeRegistry::global(cx);
|
||||
@@ -299,20 +377,22 @@ impl Workspace {
|
||||
this.width(px(520.))
|
||||
.show_close(true)
|
||||
.title("Select theme")
|
||||
.pb_4()
|
||||
.pb_2()
|
||||
.child(v_flex().gap_2().w_full().children({
|
||||
let mut items = vec![];
|
||||
|
||||
for (ix, (path, theme)) in themes.iter().enumerate() {
|
||||
items.push(
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.group("")
|
||||
.px_2()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.bg(cx.theme().ghost_element_background)
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
@@ -377,8 +457,15 @@ impl Workspace {
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.when_none(¤t_user, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Choose an account to continue...")),
|
||||
)
|
||||
})
|
||||
.when_some(current_user.as_ref(), |this, public_key| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(public_key, cx);
|
||||
@@ -427,6 +514,11 @@ impl Workspace {
|
||||
Box::new(Command::ToggleTheme),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Accounts",
|
||||
IconName::Group,
|
||||
Box::new(Command::ToggleAccount),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Settings",
|
||||
IconName::Settings,
|
||||
@@ -435,25 +527,11 @@ impl Workspace {
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(nostr.read(cx).creating(), |this| {
|
||||
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
|
||||
SharedString::from("Coop is creating a new identity for you..."),
|
||||
))
|
||||
})
|
||||
.when(!nostr.read(cx).connected(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Connecting...")),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn titlebar_right(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
let relay_list = nostr.read(cx).relay_list_state();
|
||||
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let inbox_state = chat.read(cx).state(cx);
|
||||
@@ -471,8 +549,39 @@ impl Workspace {
|
||||
.tooltip("Decoupled encryption key")
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(|_ev, window, cx| {
|
||||
window.dispatch_action(Box::new(Command::ShowEncryption), cx);
|
||||
.dropdown_menu(move |this, _window, cx| {
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let state = device.read(cx).state();
|
||||
|
||||
this.min_w(px(260.))
|
||||
.item(PopupMenuItem::element(move |_window, _cx| {
|
||||
h_flex()
|
||||
.px_1()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.size_1p5()
|
||||
.rounded_full()
|
||||
.when(state.set(), |this| this.bg(gpui::green()))
|
||||
.when(state.requesting(), |this| {
|
||||
this.bg(gpui::yellow())
|
||||
}),
|
||||
)
|
||||
.child(SharedString::from(state.to_string()))
|
||||
}))
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Reload",
|
||||
IconName::Refresh,
|
||||
Box::new(Command::RefreshEncryption),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Reset",
|
||||
IconName::Warning,
|
||||
Box::new(Command::ResetEncryption),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
@@ -552,7 +661,7 @@ impl Workspace {
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.map(|this| match relay_list {
|
||||
.map(|this| match nostr.read(cx).relay_list_state {
|
||||
RelayState::Checking => this
|
||||
.child(div().child(SharedString::from(
|
||||
"Fetching user's relay list...",
|
||||
@@ -571,7 +680,9 @@ impl Workspace {
|
||||
.tooltip("User's relay list")
|
||||
.small()
|
||||
.ghost()
|
||||
.when(relay_list.configured(), |this| this.indicator())
|
||||
.when(nostr.read(cx).relay_list_state.configured(), |this| {
|
||||
this.indicator()
|
||||
})
|
||||
.dropdown_menu(move |this, _window, cx| {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let urls = nostr.read(cx).read_only_relays(&pkey, cx);
|
||||
|
||||
@@ -8,6 +8,8 @@ publish.workspace = true
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
person = { path = "../person" }
|
||||
ui = { path = "../ui" }
|
||||
theme = { path = "../theme" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
use std::cell::Cell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||
use gpui::{
|
||||
div, App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString,
|
||||
Styled, Subscription, Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{
|
||||
app_name, Announcement, DeviceState, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::notification::Notification;
|
||||
use ui::{h_flex, v_flex, Disableable, IconName, Sizable, WindowExtension};
|
||||
|
||||
const IDENTIFIER: &str = "coop:device";
|
||||
const MSG: &str = "You've requested an encryption key from another device. \
|
||||
Approve to allow Coop to share with it.";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
|
||||
@@ -25,9 +37,6 @@ impl Global for GlobalDeviceRegistry {}
|
||||
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
#[derive(Debug)]
|
||||
pub struct DeviceRegistry {
|
||||
/// Request for encryption key from other devices
|
||||
pub requests: Vec<Event>,
|
||||
|
||||
/// Device state
|
||||
state: DeviceState,
|
||||
|
||||
@@ -57,26 +66,25 @@ impl DeviceRegistry {
|
||||
subscriptions.push(
|
||||
// Observe the NIP-65 state
|
||||
cx.observe(&nostr, |this, state, cx| {
|
||||
if state.read(cx).relay_list_state() == RelayState::Configured {
|
||||
if state.read(cx).relay_list_state == RelayState::Configured {
|
||||
this.get_announcement(cx);
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.handle_notifications(cx);
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.handle_notifications(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
requests: vec![],
|
||||
state: DeviceState::default(),
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let (tx, rx) = flume::bounded::<Event>(100);
|
||||
@@ -117,17 +125,19 @@ impl DeviceRegistry {
|
||||
|
||||
self.tasks.push(
|
||||
// Update GPUI states
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
match event.kind {
|
||||
// New request event
|
||||
Kind::Custom(4454) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.add_request(event, cx);
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.ask_for_approval(event, window, cx);
|
||||
})?;
|
||||
}
|
||||
// New response event
|
||||
Kind::Custom(4455) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.parse_response(event, cx);
|
||||
this.extract_encryption(event, cx);
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
@@ -151,7 +161,7 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Set the decoupled encryption key for the current user
|
||||
fn set_signer<S>(&mut self, new: S, cx: &mut Context<Self>)
|
||||
pub fn set_signer<S>(&mut self, new: S, cx: &mut Context<Self>)
|
||||
where
|
||||
S: NostrSigner + 'static,
|
||||
{
|
||||
@@ -174,27 +184,9 @@ impl DeviceRegistry {
|
||||
/// Reset the device state
|
||||
fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.state = DeviceState::Idle;
|
||||
self.requests.clear();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Add a request for device keys
|
||||
fn add_request(&mut self, request: Event, cx: &mut Context<Self>) {
|
||||
self.requests.push(request);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Remove a request for device keys
|
||||
pub fn remove_request(&mut self, id: &EventId, cx: &mut Context<Self>) {
|
||||
self.requests.retain(|r| r.id != *id);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Check if there are any pending requests
|
||||
pub fn has_requests(&self) -> bool {
|
||||
!self.requests.is_empty()
|
||||
}
|
||||
|
||||
/// Get all messages for encryption keys
|
||||
fn get_messages(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.subscribe_to_giftwrap_events(cx);
|
||||
@@ -212,9 +204,11 @@ impl DeviceRegistry {
|
||||
fn subscribe_to_giftwrap_events(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let Some(public_key) = signer.public_key() else {
|
||||
return Task::ready(Err(anyhow!("User not found")));
|
||||
};
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
@@ -242,12 +236,14 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Get device announcement for current user
|
||||
fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
||||
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let Some(public_key) = signer.public_key() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Reset state before fetching announcement
|
||||
self.reset(cx);
|
||||
@@ -307,14 +303,15 @@ impl DeviceRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Create a new device signer and announce it
|
||||
fn announce(&mut self, cx: &mut Context<Self>) {
|
||||
/// Create new encryption keys
|
||||
pub fn create_encryption(&self, cx: &App) -> Task<Result<Keys, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Get current user
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let Some(public_key) = signer.public_key() else {
|
||||
return Task::ready(Err(anyhow!("User not found")));
|
||||
};
|
||||
|
||||
// Get user's write relays
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
@@ -323,7 +320,7 @@ impl DeviceRegistry {
|
||||
let secret = keys.secret_key().to_secret_hex();
|
||||
let n = keys.public_key();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
|
||||
// Construct an announcement event
|
||||
@@ -340,23 +337,29 @@ impl DeviceRegistry {
|
||||
// Save device keys to the database
|
||||
set_keys(&client, &secret).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
Ok(keys)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new device signer and announce it
|
||||
fn announce(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.create_encryption(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if task.await.is_ok() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
this.listen_request(cx);
|
||||
})?;
|
||||
}
|
||||
let keys = task.await?;
|
||||
|
||||
// Update signer
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
this.listen_request(cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Initialize device signer (decoupled encryption key) for the current user
|
||||
fn new_signer(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
pub fn new_signer(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
@@ -375,36 +378,36 @@ impl DeviceRegistry {
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(keys) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
this.listen_request(cx);
|
||||
})
|
||||
.ok();
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to initialize device signer: {e}");
|
||||
this.update(cx, |this, cx| {
|
||||
this.request(cx);
|
||||
this.listen_approval(cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
log::warn!("Failed to initialize device signer: {e}");
|
||||
})?;
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Listen for device key requests on user's write relays
|
||||
fn listen_request(&mut self, cx: &mut Context<Self>) {
|
||||
pub fn listen_request(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let Some(public_key) = signer.public_key() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
@@ -434,9 +437,11 @@ impl DeviceRegistry {
|
||||
fn listen_approval(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let Some(public_key) = signer.public_key() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
@@ -464,13 +469,15 @@ impl DeviceRegistry {
|
||||
fn request(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let Some(public_key) = signer.public_key() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let app_keys = nostr.read(cx).app_keys().clone();
|
||||
let app_keys = nostr.read(cx).app_keys.clone();
|
||||
let app_pubkey = app_keys.public_key();
|
||||
|
||||
let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move {
|
||||
@@ -518,32 +525,31 @@ impl DeviceRegistry {
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(Some(keys)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})
|
||||
.ok();
|
||||
})?;
|
||||
}
|
||||
Ok(None) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(DeviceState::Requesting, cx);
|
||||
})
|
||||
.ok();
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to request the encryption key: {e}");
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Parse the response event for device keys from other devices
|
||||
fn parse_response(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let app_keys = nostr.read(cx).app_keys().clone();
|
||||
let app_keys = nostr.read(cx).app_keys.clone();
|
||||
|
||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||
let root_device = event
|
||||
@@ -575,19 +581,21 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Approve requests for device keys from other devices
|
||||
pub fn approve(&self, event: &Event, cx: &App) -> Task<Result<(), Error>> {
|
||||
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Get current user
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let Some(public_key) = signer.public_key() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get user's write relays
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
let event = event.clone();
|
||||
let id: SharedString = event.id.to_hex().into();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
|
||||
// Get device keys
|
||||
@@ -609,18 +617,145 @@ impl DeviceRegistry {
|
||||
//
|
||||
// P tag: the current device's public key
|
||||
// p tag: the requester's public key
|
||||
let event = client
|
||||
.sign_event_builder(EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
|
||||
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
|
||||
Tag::public_key(target),
|
||||
]))
|
||||
.await?;
|
||||
let builder = EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
|
||||
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
|
||||
Tag::public_key(target),
|
||||
]);
|
||||
|
||||
// Sign the builder
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
|
||||
// Send the response event to the user's relay list
|
||||
client.send_event(&event).to(urls).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
cx.update(|window, cx| {
|
||||
window.clear_notification(id, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Handle encryption request
|
||||
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let notification = self.notification(event, cx);
|
||||
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(notification, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Build a notification for the encryption request.
|
||||
fn notification(&self, event: Event, cx: &Context<Self>) -> Notification {
|
||||
let request = Announcement::from(&event);
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&request.public_key(), cx);
|
||||
|
||||
let entity = cx.entity().downgrade();
|
||||
let loading = Rc::new(Cell::new(false));
|
||||
|
||||
Notification::new()
|
||||
.custom_id(SharedString::from(event.id.to_hex()))
|
||||
.autohide(false)
|
||||
.icon(IconName::UserKey)
|
||||
.title(SharedString::from("New request"))
|
||||
.content(move |_window, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(SharedString::from(MSG))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Requester:")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.h_7()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Avatar::new(profile.avatar()).xsmall())
|
||||
.child(profile.name()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Client:")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.h_7()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(request.client_name()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.action(move |_window, _cx| {
|
||||
let view = entity.clone();
|
||||
let event = event.clone();
|
||||
|
||||
Button::new("approve")
|
||||
.label("Approve")
|
||||
.small()
|
||||
.primary()
|
||||
.loading(loading.get())
|
||||
.disabled(loading.get())
|
||||
.on_click({
|
||||
let loading = Rc::clone(&loading);
|
||||
move |_ev, window, cx| {
|
||||
// Set loading state to true
|
||||
loading.set(true);
|
||||
// Process to approve the request
|
||||
view.update(cx, |this, cx| {
|
||||
this.approve(&event, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -343,8 +343,8 @@ impl RelayAuth {
|
||||
.px_1p5()
|
||||
.rounded_sm()
|
||||
.text_xs()
|
||||
.bg(cx.theme().warning_background)
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.text_color(cx.theme().text_accent)
|
||||
.child(url.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
@@ -361,11 +361,9 @@ impl RelayAuth {
|
||||
.disabled(loading.get())
|
||||
.on_click({
|
||||
let loading = Rc::clone(&loading);
|
||||
|
||||
move |_ev, window, cx| {
|
||||
// Set loading state to true
|
||||
loading.set(true);
|
||||
|
||||
// Process to approve the request
|
||||
view.update(cx, |this, cx| {
|
||||
this.response(&req, window, cx);
|
||||
|
||||
@@ -2,12 +2,12 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Display;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::{Theme, ThemeFamily, ThemeMode};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
@@ -291,6 +291,8 @@ impl AppSettings {
|
||||
/// Reset theme
|
||||
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.values.theme = None;
|
||||
cx.notify();
|
||||
|
||||
self.apply_theme(window, cx);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,18 +21,18 @@ pub const FIND_DELAY: u64 = 600;
|
||||
/// Default limit for searching
|
||||
pub const FIND_LIMIT: usize = 20;
|
||||
|
||||
/// Default timeout for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
|
||||
|
||||
/// Default Nostr Connect relay
|
||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
|
||||
|
||||
/// Default subscription id for device gift wrap events
|
||||
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
|
||||
|
||||
/// Default subscription id for user gift wrap events
|
||||
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
|
||||
|
||||
/// Default timeout for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||
|
||||
/// Default Nostr Connect relay
|
||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
|
||||
|
||||
/// Default vertex relays
|
||||
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
||||
|
||||
@@ -40,10 +40,9 @@ pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
||||
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||
|
||||
/// Default bootstrap relays
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
"wss://nos.lol",
|
||||
"wss://relay.damus.io",
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||
"wss://relay.primal.net",
|
||||
"wss://indexer.coracle.social",
|
||||
"wss://user.kindpag.es",
|
||||
];
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
@@ -9,6 +11,16 @@ pub enum DeviceState {
|
||||
Set,
|
||||
}
|
||||
|
||||
impl Display for DeviceState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DeviceState::Idle => write!(f, "Idle"),
|
||||
DeviceState::Requesting => write!(f, "Wait for approval"),
|
||||
DeviceState::Set => write!(f, "Encryption Key is ready"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceState {
|
||||
pub fn idle(&self) -> bool {
|
||||
matches!(self, DeviceState::Idle)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Task, Window};
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_lmdb::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -51,22 +50,19 @@ pub struct NostrRegistry {
|
||||
/// Nostr signer
|
||||
signer: Arc<CoopSigner>,
|
||||
|
||||
/// App keys
|
||||
///
|
||||
/// Used for Nostr Connect and NIP-4e operations
|
||||
app_keys: Keys,
|
||||
/// Local public keys
|
||||
npubs: Entity<Vec<PublicKey>>,
|
||||
|
||||
/// Custom gossip implementation
|
||||
gossip: Entity<Gossip>,
|
||||
|
||||
/// App keys
|
||||
///
|
||||
/// Used for Nostr Connect and NIP-4e operations
|
||||
pub app_keys: Keys,
|
||||
|
||||
/// Relay list state
|
||||
relay_list_state: RelayState,
|
||||
|
||||
/// Whether Coop is connected to all bootstrap relays
|
||||
connected: bool,
|
||||
|
||||
/// Whether Coop is creating a new signer
|
||||
creating: bool,
|
||||
pub relay_list_state: RelayState,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
@@ -89,6 +85,9 @@ impl NostrRegistry {
|
||||
let app_keys = get_or_init_app_keys().unwrap_or(Keys::generate());
|
||||
let signer = Arc::new(CoopSigner::new(app_keys.clone()));
|
||||
|
||||
// Construct the nostr npubs entity
|
||||
let npubs = cx.new(|_| vec![]);
|
||||
|
||||
// Construct the gossip entity
|
||||
let gossip = cx.new(|_| Gossip::default());
|
||||
|
||||
@@ -120,15 +119,30 @@ impl NostrRegistry {
|
||||
Self {
|
||||
client,
|
||||
signer,
|
||||
npubs,
|
||||
app_keys,
|
||||
gossip,
|
||||
relay_list_state: RelayState::Idle,
|
||||
connected: false,
|
||||
creating: false,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the nostr client
|
||||
pub fn client(&self) -> Client {
|
||||
self.client.clone()
|
||||
}
|
||||
|
||||
/// Get the nostr signer
|
||||
pub fn signer(&self) -> Arc<CoopSigner> {
|
||||
self.signer.clone()
|
||||
}
|
||||
|
||||
/// Get the npubs entity
|
||||
pub fn npubs(&self) -> Entity<Vec<PublicKey>> {
|
||||
self.npubs.clone()
|
||||
}
|
||||
|
||||
/// Connect to the bootstrapping relays
|
||||
fn connect(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client();
|
||||
|
||||
@@ -146,14 +160,13 @@ impl NostrRegistry {
|
||||
}
|
||||
|
||||
// Connect to all added relays
|
||||
client.connect().and_wait(Duration::from_secs(5)).await;
|
||||
client.connect().and_wait(Duration::from_secs(2)).await;
|
||||
})
|
||||
.await;
|
||||
|
||||
// Update the state
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_connected(cx);
|
||||
this.get_signer(cx);
|
||||
this.get_npubs(cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
@@ -214,39 +227,435 @@ impl NostrRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get the nostr client
|
||||
pub fn client(&self) -> Client {
|
||||
self.client.clone()
|
||||
/// Get all used npubs
|
||||
fn get_npubs(&mut self, cx: &mut Context<Self>) {
|
||||
let npubs = self.npubs.downgrade();
|
||||
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||
let dir = config_dir().join("keys");
|
||||
// Ensure keys directory exists
|
||||
smol::fs::create_dir_all(&dir).await?;
|
||||
|
||||
let mut files = smol::fs::read_dir(&dir).await?;
|
||||
let mut entries = Vec::new();
|
||||
|
||||
while let Some(Ok(entry)) = files.next().await {
|
||||
let metadata = entry.metadata().await?;
|
||||
let modified_time = metadata.modified()?;
|
||||
let name = entry
|
||||
.file_name()
|
||||
.into_string()
|
||||
.unwrap()
|
||||
.replace(".npub", "");
|
||||
|
||||
entries.push((modified_time, name));
|
||||
}
|
||||
|
||||
// Sort by modification time (most recent first)
|
||||
entries.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
let mut npubs = Vec::new();
|
||||
|
||||
for (_, name) in entries {
|
||||
let public_key = PublicKey::parse(&name)?;
|
||||
npubs.push(public_key);
|
||||
}
|
||||
|
||||
Ok(npubs)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(public_keys) => match public_keys.is_empty() {
|
||||
true => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.create_identity(cx);
|
||||
})?;
|
||||
}
|
||||
false => {
|
||||
// TODO: auto login
|
||||
npubs.update(cx, |this, cx| {
|
||||
this.extend(public_keys);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to get npubs: {e}");
|
||||
this.update(cx, |this, cx| {
|
||||
this.create_identity(cx);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get the nostr signer
|
||||
pub fn signer(&self) -> Arc<CoopSigner> {
|
||||
self.signer.clone()
|
||||
/// Create a new identity
|
||||
fn create_identity(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client();
|
||||
let keys = Keys::generate();
|
||||
let async_keys = keys.clone();
|
||||
|
||||
let username = keys.public_key().to_bech32().unwrap();
|
||||
let secret = keys.secret_key().to_secret_bytes();
|
||||
|
||||
// Create a write credential task
|
||||
let write_credential = cx.write_credentials(&username, &username, &secret);
|
||||
|
||||
// Run async tasks in background
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = async_keys.into_nostr_signer();
|
||||
|
||||
// Get default relay list
|
||||
let relay_list = default_relay_list();
|
||||
|
||||
// Extract write relays
|
||||
let write_urls: Vec<RelayUrl> = relay_list
|
||||
.iter()
|
||||
.filter_map(|(url, metadata)| {
|
||||
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
|
||||
Some(url)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Ensure connected to all relays
|
||||
for (url, _metadata) in relay_list.iter() {
|
||||
client.add_relay(url).and_connect().await?;
|
||||
}
|
||||
|
||||
// Publish relay list event
|
||||
let event = EventBuilder::relay_list(relay_list).sign(&signer).await?;
|
||||
let output = client
|
||||
.send_event(&event)
|
||||
.to(BOOTSTRAP_RELAYS)
|
||||
.ok_timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
log::info!("Sent gossip relay list: {output:?}");
|
||||
|
||||
// Construct the default metadata
|
||||
let name = petname::petname(2, "-").unwrap_or("Cooper".to_string());
|
||||
let avatar = Url::parse(&format!("https://avatar.vercel.sh/{name}")).unwrap();
|
||||
let metadata = Metadata::new().display_name(&name).picture(avatar);
|
||||
|
||||
// Publish metadata event
|
||||
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
|
||||
client
|
||||
.send_event(&event)
|
||||
.to(&write_urls)
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await?;
|
||||
|
||||
// Construct the default contact list
|
||||
let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())];
|
||||
|
||||
// Publish contact list event
|
||||
let event = EventBuilder::contact_list(contacts).sign(&signer).await?;
|
||||
client
|
||||
.send_event(&event)
|
||||
.to(&write_urls)
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await?;
|
||||
|
||||
// Construct the default messaging relay list
|
||||
let relays = default_messaging_relays();
|
||||
|
||||
// Ensure connected to all relays
|
||||
for url in relays.iter() {
|
||||
client.add_relay(url).and_connect().await?;
|
||||
}
|
||||
|
||||
// Publish messaging relay list event
|
||||
let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?;
|
||||
client
|
||||
.send_event(&event)
|
||||
.to(&write_urls)
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await?;
|
||||
|
||||
// Write user's credentials to the system keyring
|
||||
write_credential.await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
// Wait for the task to complete
|
||||
task.await?;
|
||||
|
||||
// Set signer
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get the app keys
|
||||
pub fn app_keys(&self) -> &Keys {
|
||||
&self.app_keys
|
||||
/// Get the signer in keyring by username
|
||||
pub fn get_signer(
|
||||
&self,
|
||||
public_key: &PublicKey,
|
||||
cx: &App,
|
||||
) -> Task<Result<Arc<dyn NostrSigner>, Error>> {
|
||||
let username = public_key.to_bech32().unwrap();
|
||||
let app_keys = self.app_keys.clone();
|
||||
let read_credential = cx.read_credentials(&username);
|
||||
|
||||
cx.spawn(async move |_cx| {
|
||||
let (_, secret) = read_credential
|
||||
.await
|
||||
.map_err(|_| anyhow!("Failed to get signer. Please re-import the secret key"))?
|
||||
.ok_or_else(|| anyhow!("Failed to get signer. Please re-import the secret key"))?;
|
||||
|
||||
// Try to parse as a direct secret key first
|
||||
if let Ok(secret_key) = SecretKey::from_slice(&secret) {
|
||||
return Ok(Keys::new(secret_key).into_nostr_signer());
|
||||
}
|
||||
|
||||
// Convert the secret into string
|
||||
let sec = String::from_utf8(secret)
|
||||
.map_err(|_| anyhow!("Failed to parse secret as UTF-8"))?;
|
||||
|
||||
// Try to parse as a NIP-46 URI
|
||||
let uri =
|
||||
NostrConnectUri::parse(&sec).map_err(|_| anyhow!("Failed to parse NIP-46 URI"))?;
|
||||
|
||||
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||
let mut nip46 = NostrConnect::new(uri, app_keys, timeout, None)?;
|
||||
|
||||
// Set the auth URL handler
|
||||
nip46.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
Ok(nip46.into_nostr_signer())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the connected status of the client
|
||||
pub fn connected(&self) -> bool {
|
||||
self.connected
|
||||
/// Set the signer for the nostr client and verify the public key
|
||||
pub fn set_signer<T>(&mut self, new: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
let client = self.client();
|
||||
let signer = self.signer();
|
||||
|
||||
// Create a task to update the signer and verify the public key
|
||||
let task: Task<Result<PublicKey, Error>> = cx.background_spawn(async move {
|
||||
// Update signer and unsubscribe
|
||||
signer.switch(new).await;
|
||||
client.unsubscribe_all().await?;
|
||||
|
||||
// Verify and get public key
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let npub = public_key.to_bech32().unwrap();
|
||||
let keys_dir = config_dir().join("keys");
|
||||
|
||||
// Ensure keys directory exists
|
||||
smol::fs::create_dir_all(&keys_dir).await?;
|
||||
|
||||
let key_path = keys_dir.join(format!("{}.npub", npub));
|
||||
smol::fs::write(key_path, "").await?;
|
||||
|
||||
log::info!("Signer's public key: {}", public_key);
|
||||
Ok(public_key)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(public_key) => {
|
||||
// Update states
|
||||
this.update(cx, |this, cx| {
|
||||
// Add public key to npubs if not already present
|
||||
this.npubs.update(cx, |this, cx| {
|
||||
if !this.contains(&public_key) {
|
||||
this.push(public_key);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure relay list for the user
|
||||
this.ensure_relay_list(cx);
|
||||
|
||||
// Emit signer changed event
|
||||
cx.emit(SignerEvent::Set);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(SignerEvent::Error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get the creating status
|
||||
pub fn creating(&self) -> bool {
|
||||
self.creating
|
||||
/// Remove a signer from the keyring
|
||||
pub fn remove_signer(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
||||
let public_key = public_key.to_owned();
|
||||
let npub = public_key.to_bech32().unwrap();
|
||||
let keys_dir = config_dir().join("keys");
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let key_path = keys_dir.join(format!("{}.npub", npub));
|
||||
smol::fs::remove_file(key_path).await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.npubs().update(cx, |this, cx| {
|
||||
this.retain(|k| k != &public_key);
|
||||
cx.notify();
|
||||
});
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get the relay list state
|
||||
pub fn relay_list_state(&self) -> RelayState {
|
||||
self.relay_list_state.clone()
|
||||
/// Add a key signer to keyring
|
||||
pub fn add_key_signer(&mut self, keys: &Keys, cx: &mut Context<Self>) {
|
||||
let keys = keys.clone();
|
||||
let username = keys.public_key().to_bech32().unwrap();
|
||||
let secret = keys.secret_key().to_secret_bytes();
|
||||
|
||||
// Write the credential to the keyring
|
||||
let write_credential = cx.write_credentials(&username, "keys", &secret);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match write_credential.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(SignerEvent::Error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get all relays for a given public key without ensuring connections
|
||||
pub fn read_only_relays(&self, public_key: &PublicKey, cx: &App) -> Vec<SharedString> {
|
||||
self.gossip.read(cx).read_only_relays(public_key)
|
||||
/// Add a nostr connect signer to keyring
|
||||
pub fn add_nip46_signer(&mut self, nip46: &NostrConnect, cx: &mut Context<Self>) {
|
||||
let nip46 = nip46.clone();
|
||||
let async_nip46 = nip46.clone();
|
||||
|
||||
// Connect and verify the remote signer
|
||||
let task: Task<Result<(PublicKey, NostrConnectUri), Error>> =
|
||||
cx.background_spawn(async move {
|
||||
let uri = async_nip46.bunker_uri().await?;
|
||||
let public_key = async_nip46.get_public_key().await?;
|
||||
|
||||
Ok((public_key, uri))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok((public_key, uri)) => {
|
||||
let username = public_key.to_bech32().unwrap();
|
||||
let write_credential = this.read_with(cx, |_this, cx| {
|
||||
cx.write_credentials(&username, "nostrconnect", uri.to_string().as_bytes())
|
||||
})?;
|
||||
|
||||
match write_credential.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(nip46, cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(SignerEvent::Error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(SignerEvent::Error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set the state of the relay list
|
||||
fn set_relay_state(&mut self, state: RelayState, cx: &mut Context<Self>) {
|
||||
self.relay_list_state = state;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn ensure_relay_list(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.verify_relay_list(cx);
|
||||
|
||||
// Set the state to idle before starting the task
|
||||
self.set_relay_state(RelayState::default(), cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await?;
|
||||
|
||||
// Update state
|
||||
this.update(cx, |this, cx| {
|
||||
this.relay_list_state = result;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
// Verify relay list for current user
|
||||
fn verify_relay_list(&mut self, cx: &mut Context<Self>) -> Task<Result<RelayState, Error>> {
|
||||
let client = self.client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::RelayList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
||||
.into_iter()
|
||||
.map(|relay| (relay, vec![filter.clone()]))
|
||||
.collect();
|
||||
|
||||
// Stream events from the bootstrap relays
|
||||
let mut stream = client
|
||||
.stream_events(target)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
log::info!("Received relay list event: {event:?}");
|
||||
return Ok(RelayState::Configured);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to receive relay list event: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RelayState::NotConfigured)
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensure write relays for a given public key
|
||||
@@ -336,316 +745,9 @@ impl NostrRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the connected status of the client
|
||||
fn set_connected(&mut self, cx: &mut Context<Self>) {
|
||||
self.connected = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Get local stored signer
|
||||
fn get_signer(&mut self, cx: &mut Context<Self>) {
|
||||
let read_credential = cx.read_credentials(KEYRING);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match read_credential.await {
|
||||
Ok(Some((_user, secret))) => {
|
||||
let secret = SecretKey::from_slice(&secret)?;
|
||||
let keys = Keys::new(secret);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, false, cx);
|
||||
})?;
|
||||
}
|
||||
_ => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_bunker(cx);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get local stored bunker connection
|
||||
fn get_bunker(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client();
|
||||
let app_keys = self.app_keys().clone();
|
||||
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||
|
||||
let task: Task<Result<NostrConnect, Error>> = cx.background_spawn(async move {
|
||||
log::info!("Getting bunker connection");
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier("coop:account")
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let uri = NostrConnectUri::parse(event.content)?;
|
||||
let signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None)?;
|
||||
|
||||
Ok(signer)
|
||||
} else {
|
||||
Err(anyhow!("No account found"))
|
||||
}
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(signer) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, true, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to get bunker: {e}");
|
||||
// Create a new identity if no stored bunker exists
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_default_signer(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set the signer for the nostr client and verify the public key
|
||||
pub fn set_signer<T>(&mut self, new: T, owned: bool, cx: &mut Context<Self>)
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
let client = self.client();
|
||||
let signer = self.signer();
|
||||
|
||||
// Create a task to update the signer and verify the public key
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
// Update signer
|
||||
signer.switch(new, owned).await;
|
||||
|
||||
// Unsubscribe from all subscriptions
|
||||
client.unsubscribe_all().await?;
|
||||
|
||||
// Verify signer
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
log::info!("Signer's public key: {}", public_key);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
// set signer
|
||||
task.await?;
|
||||
|
||||
// Update states
|
||||
this.update(cx, |this, cx| {
|
||||
this.ensure_relay_list(cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Create a new identity
|
||||
fn set_default_signer(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client();
|
||||
let keys = Keys::generate();
|
||||
let async_keys = keys.clone();
|
||||
|
||||
// Create a write credential task
|
||||
let write_credential = cx.write_credentials(
|
||||
KEYRING,
|
||||
&keys.public_key().to_hex(),
|
||||
&keys.secret_key().to_secret_bytes(),
|
||||
);
|
||||
|
||||
// Set the creating signer status
|
||||
self.set_creating_signer(true, cx);
|
||||
|
||||
// Run async tasks in background
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = async_keys.into_nostr_signer();
|
||||
|
||||
// Get default relay list
|
||||
let relay_list = default_relay_list();
|
||||
|
||||
// Publish relay list event
|
||||
let event = EventBuilder::relay_list(relay_list).sign(&signer).await?;
|
||||
client
|
||||
.send_event(&event)
|
||||
.ok_timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
// Construct the default metadata
|
||||
let name = petname::petname(2, "-").unwrap_or("Cooper".to_string());
|
||||
let avatar = Url::parse(&format!("https://avatar.vercel.sh/{name}")).unwrap();
|
||||
let metadata = Metadata::new().display_name(&name).picture(avatar);
|
||||
|
||||
// Publish metadata event
|
||||
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
|
||||
client
|
||||
.send_event(&event)
|
||||
.ok_timeout(Duration::from_secs(TIMEOUT))
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await?;
|
||||
|
||||
// Construct the default contact list
|
||||
let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())];
|
||||
|
||||
// Publish contact list event
|
||||
let event = EventBuilder::contact_list(contacts).sign(&signer).await?;
|
||||
client
|
||||
.send_event(&event)
|
||||
.ok_timeout(Duration::from_secs(TIMEOUT))
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await?;
|
||||
|
||||
// Construct the default messaging relay list
|
||||
let relays = default_messaging_relays();
|
||||
|
||||
// Publish messaging relay list event
|
||||
let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?;
|
||||
client
|
||||
.send_event(&event)
|
||||
.ok_timeout(Duration::from_secs(TIMEOUT))
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await?;
|
||||
|
||||
// Write user's credentials to the system keyring
|
||||
write_credential.await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
// Wait for the task to complete
|
||||
task.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_creating_signer(false, cx);
|
||||
this.set_signer(keys, false, cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set whether Coop is creating a new signer
|
||||
fn set_creating_signer(&mut self, creating: bool, cx: &mut Context<Self>) {
|
||||
self.creating = creating;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Set the state of the relay list
|
||||
fn set_relay_state(&mut self, state: RelayState, cx: &mut Context<Self>) {
|
||||
self.relay_list_state = state;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn ensure_relay_list(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.verify_relay_list(cx);
|
||||
|
||||
// Set the state to idle before starting the task
|
||||
self.set_relay_state(RelayState::default(), cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await?;
|
||||
|
||||
// Update state
|
||||
this.update(cx, |this, cx| {
|
||||
this.relay_list_state = result;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
// Verify relay list for current user
|
||||
fn verify_relay_list(&mut self, cx: &mut Context<Self>) -> Task<Result<RelayState, Error>> {
|
||||
let client = self.client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::RelayList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
||||
.into_iter()
|
||||
.map(|relay| (relay, vec![filter.clone()]))
|
||||
.collect();
|
||||
|
||||
// Stream events from the bootstrap relays
|
||||
let mut stream = client
|
||||
.stream_events(target)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
log::info!("Received relay list event: {event:?}");
|
||||
return Ok(RelayState::Configured);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to receive relay list event: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RelayState::NotConfigured)
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate a direct nostr connection initiated by the client
|
||||
pub fn client_connect(&self, relay: Option<RelayUrl>) -> (NostrConnect, NostrConnectUri) {
|
||||
let app_keys = self.app_keys();
|
||||
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||
|
||||
// Determine the relay will be used for Nostr Connect
|
||||
let relay = match relay {
|
||||
Some(relay) => relay,
|
||||
None => RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap(),
|
||||
};
|
||||
|
||||
// Generate the nostr connect uri
|
||||
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
|
||||
|
||||
// Generate the nostr connect
|
||||
let mut signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap();
|
||||
|
||||
// Handle the auth request
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
(signer, uri)
|
||||
}
|
||||
|
||||
/// Store the bunker connection for the next login
|
||||
pub fn persist_bunker(&mut self, uri: NostrConnectUri, cx: &mut App) {
|
||||
let client = self.client();
|
||||
let rng_keys = Keys::generate();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
// Construct the event for application-specific data
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, uri.to_string())
|
||||
.tag(Tag::identifier("coop:account"))
|
||||
.sign(&rng_keys)
|
||||
.await?;
|
||||
|
||||
// Store the event in the database
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
/// Get all relays for a given public key without ensuring connections
|
||||
pub fn read_only_relays(&self, public_key: &PublicKey, cx: &App) -> Vec<SharedString> {
|
||||
self.gossip.read(cx).read_only_relays(public_key)
|
||||
}
|
||||
|
||||
/// Get the public key of a NIP-05 address
|
||||
@@ -803,6 +905,8 @@ impl NostrRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<SignerEvent> for NostrRegistry {}
|
||||
|
||||
/// Get or create a new app keys
|
||||
fn get_or_init_app_keys() -> Result<Keys, Error> {
|
||||
let dir = config_dir().join(".app_keys");
|
||||
@@ -818,11 +922,6 @@ fn get_or_init_app_keys() -> Result<Keys, Error> {
|
||||
std::fs::create_dir_all(dir.parent().unwrap())?;
|
||||
std::fs::write(&dir, secret_key.to_secret_bytes())?;
|
||||
|
||||
// Set permissions to readonly
|
||||
let mut perms = std::fs::metadata(&dir)?.permissions();
|
||||
perms.set_mode(0o400);
|
||||
std::fs::set_permissions(&dir, perms)?;
|
||||
|
||||
return Ok(keys);
|
||||
}
|
||||
};
|
||||
@@ -882,7 +981,7 @@ async fn get_events_for_room(client: &Client, nip65: &Event) -> Result<(), Error
|
||||
fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
|
||||
vec![
|
||||
(
|
||||
RelayUrl::parse("wss://relay.gulugulu.moe").unwrap(),
|
||||
RelayUrl::parse("wss://relay.nostr.net").unwrap(),
|
||||
Some(RelayMetadata::Write),
|
||||
),
|
||||
(
|
||||
@@ -912,6 +1011,16 @@ fn default_messaging_relays() -> Vec<RelayUrl> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Signer event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SignerEvent {
|
||||
/// A new signer has been set
|
||||
Set,
|
||||
|
||||
/// An error occurred
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum RelayState {
|
||||
#[default]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
use std::result::Result;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -16,11 +15,6 @@ pub struct CoopSigner {
|
||||
|
||||
/// Specific signer for encryption purposes
|
||||
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
|
||||
|
||||
/// By default, Coop generates a new signer for new users.
|
||||
///
|
||||
/// This flag indicates whether the signer is user-owned or Coop-generated.
|
||||
owned: AtomicBool,
|
||||
}
|
||||
|
||||
impl CoopSigner {
|
||||
@@ -32,7 +26,6 @@ impl CoopSigner {
|
||||
signer: RwLock::new(signer.into_nostr_signer()),
|
||||
signer_pkey: RwLock::new(None),
|
||||
encryption_signer: RwLock::new(None),
|
||||
owned: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,17 +40,15 @@ impl CoopSigner {
|
||||
}
|
||||
|
||||
/// Get public key
|
||||
///
|
||||
/// Ensure to call this method after the signer has been initialized.
|
||||
/// Otherwise, this method will panic.
|
||||
pub fn public_key(&self) -> Option<PublicKey> {
|
||||
self.signer_pkey.read_blocking().to_owned()
|
||||
}
|
||||
|
||||
/// Get the flag indicating whether the signer is user-owned.
|
||||
pub fn owned(&self) -> bool {
|
||||
self.owned.load(Ordering::SeqCst)
|
||||
*self.signer_pkey.read_blocking()
|
||||
}
|
||||
|
||||
/// Switch the current signer to a new signer.
|
||||
pub async fn switch<T>(&self, new: T, owned: bool)
|
||||
pub async fn switch<T>(&self, new: T)
|
||||
where
|
||||
T: IntoNostrSigner,
|
||||
{
|
||||
@@ -75,9 +66,6 @@ impl CoopSigner {
|
||||
|
||||
// Reset the encryption signer
|
||||
*encryption_signer = None;
|
||||
|
||||
// Update the owned flag
|
||||
self.owned.store(owned, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Set the encryption signer.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{px, App, Global, Pixels, SharedString, Window};
|
||||
use gpui::{App, Global, Pixels, SharedString, Window, px};
|
||||
|
||||
mod colors;
|
||||
mod platform_kind;
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
#[cfg(target_os = "linux")]
|
||||
use gpui::MouseButton;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use gpui::Pixels;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement,
|
||||
ParentElement, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea,
|
||||
AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, ParentElement,
|
||||
Pixels, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, px,
|
||||
};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, PlatformKind};
|
||||
use ui::h_flex;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -2,10 +2,11 @@ use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity,
|
||||
EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window,
|
||||
actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Decorations,
|
||||
Edges, Entity, EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window,
|
||||
};
|
||||
use theme::CLIENT_SIDE_DECORATION_ROUNDING;
|
||||
|
||||
use crate::dock_area::dock::{Dock, DockPlacement};
|
||||
use crate::dock_area::panel::{Panel, PanelEvent, PanelStyle, PanelView};
|
||||
@@ -202,19 +203,16 @@ impl DockItem {
|
||||
/// Returns all panel ids
|
||||
pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> {
|
||||
match self {
|
||||
Self::Tabs { view, .. } => view.read(cx).panel_ids(cx),
|
||||
Self::Split { items, .. } => {
|
||||
let mut total = vec![];
|
||||
|
||||
for item in items.iter() {
|
||||
if let DockItem::Tabs { view, .. } = item {
|
||||
total.extend(view.read(cx).panel_ids(cx));
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
Self::Panel { .. } => vec![],
|
||||
Self::Tabs { view, .. } => view.read(cx).panel_ids(cx),
|
||||
Self::Split { items, .. } => items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
DockItem::Tabs { view, .. } => Some(view.read(cx).panel_ids(cx)),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,6 +743,7 @@ impl EventEmitter<DockEvent> for DockArea {}
|
||||
impl Render for DockArea {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let view = cx.entity().clone();
|
||||
let decorations = window.window_decorations();
|
||||
|
||||
div()
|
||||
.id("dock-area")
|
||||
@@ -754,7 +753,17 @@ impl Render for DockArea {
|
||||
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
|
||||
.map(|this| {
|
||||
if let Some(zoom_view) = self.zoom_view.clone() {
|
||||
this.child(zoom_view)
|
||||
this.map(|this| match decorations {
|
||||
Decorations::Server => this,
|
||||
Decorations::Client { tiling } => this
|
||||
.when(!(tiling.top || tiling.right), |div| {
|
||||
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.when(!(tiling.top || tiling.left), |div| {
|
||||
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
}),
|
||||
})
|
||||
.child(zoom_view)
|
||||
} else {
|
||||
// render dock
|
||||
this.child(
|
||||
|
||||
@@ -1080,8 +1080,10 @@ impl TabPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(panel) = self.active_panel(cx) {
|
||||
self.remove_panel(&panel, window, cx);
|
||||
if self.panels.len() > 1 {
|
||||
if let Some(panel) = self.active_panel(cx) {
|
||||
self.remove_panel(&panel, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ pub enum IconName {
|
||||
CloseCircle,
|
||||
CloseCircleFill,
|
||||
Copy,
|
||||
Device,
|
||||
Door,
|
||||
Ellipsis,
|
||||
Emoji,
|
||||
@@ -52,12 +53,14 @@ pub enum IconName {
|
||||
Relay,
|
||||
Reply,
|
||||
Refresh,
|
||||
Scan,
|
||||
Search,
|
||||
Settings,
|
||||
Settings2,
|
||||
Sun,
|
||||
Ship,
|
||||
Shield,
|
||||
Group,
|
||||
UserKey,
|
||||
Upload,
|
||||
Usb,
|
||||
@@ -102,6 +105,7 @@ impl IconNamed for IconName {
|
||||
Self::CloseCircle => "icons/close-circle.svg",
|
||||
Self::CloseCircleFill => "icons/close-circle-fill.svg",
|
||||
Self::Copy => "icons/copy.svg",
|
||||
Self::Device => "icons/device.svg",
|
||||
Self::Door => "icons/door.svg",
|
||||
Self::Ellipsis => "icons/ellipsis.svg",
|
||||
Self::Emoji => "icons/emoji.svg",
|
||||
@@ -120,6 +124,7 @@ impl IconNamed for IconName {
|
||||
Self::Relay => "icons/relay.svg",
|
||||
Self::Reply => "icons/reply.svg",
|
||||
Self::Refresh => "icons/refresh.svg",
|
||||
Self::Scan => "icons/scan.svg",
|
||||
Self::Search => "icons/search.svg",
|
||||
Self::Settings => "icons/settings.svg",
|
||||
Self::Settings2 => "icons/settings2.svg",
|
||||
@@ -129,6 +134,7 @@ impl IconNamed for IconName {
|
||||
Self::UserKey => "icons/user-key.svg",
|
||||
Self::Upload => "icons/upload.svg",
|
||||
Self::Usb => "icons/usb.svg",
|
||||
Self::Group => "icons/group.svg",
|
||||
Self::PanelLeft => "icons/panel-left.svg",
|
||||
Self::PanelLeftOpen => "icons/panel-left-open.svg",
|
||||
Self::PanelRight => "icons/panel-right.svg",
|
||||
|
||||
@@ -343,7 +343,7 @@ impl RenderOnce for Modal {
|
||||
});
|
||||
|
||||
let window_paddings = crate::root::window_paddings(window, cx);
|
||||
let radius = (cx.theme().radius_lg * 2.).min(px(20.));
|
||||
let radius = cx.theme().radius_lg;
|
||||
|
||||
let view_size = window.viewport_size()
|
||||
- gpui::size(
|
||||
@@ -360,8 +360,8 @@ impl RenderOnce for Modal {
|
||||
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
||||
let x = bounds.center().x - self.width / 2.;
|
||||
|
||||
let mut padding_right = px(16.);
|
||||
let mut padding_left = px(16.);
|
||||
let mut padding_right = px(8.);
|
||||
let mut padding_left = px(8.);
|
||||
|
||||
if let Some(pl) = self.style.padding.left {
|
||||
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
|
||||
|
||||
@@ -2,10 +2,10 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
canvas, div, point, px, size, AnyView, App, AppContext, Bounds, Context, CursorStyle,
|
||||
Decorations, Edges, Entity, FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement as _, Pixels, Point, Render, ResizeEdge, SharedString, Size, Styled,
|
||||
Tiling, WeakFocusHandle, Window,
|
||||
AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, Edges, Entity,
|
||||
FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement as _, Pixels, Point, Render, ResizeEdge, SharedString, Size, Styled, Tiling,
|
||||
WeakFocusHandle, Window, canvas, div, point, px, size,
|
||||
};
|
||||
use theme::{
|
||||
ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
|
||||
@@ -249,7 +249,6 @@ impl Render for Root {
|
||||
div()
|
||||
.id("window")
|
||||
.size_full()
|
||||
.bg(gpui::transparent_black())
|
||||
.map(|div| match decorations {
|
||||
Decorations::Server => div,
|
||||
Decorations::Client { tiling } => div
|
||||
|
||||
@@ -3,13 +3,13 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window,
|
||||
App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
|
||||
};
|
||||
|
||||
use super::{Scrollbar, ScrollbarAxis};
|
||||
use crate::scroll::ScrollbarHandle;
|
||||
use crate::StyledExt;
|
||||
use crate::scroll::ScrollbarHandle;
|
||||
|
||||
/// A trait for elements that can be made scrollable with scrollbars.
|
||||
pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
|
||||
@@ -160,6 +160,7 @@ where
|
||||
}
|
||||
|
||||
impl ScrollableElement for Div {}
|
||||
|
||||
impl<E> ScrollableElement for Stateful<E>
|
||||
where
|
||||
E: ParentElement + Styled + Element,
|
||||
@@ -195,6 +196,7 @@ fn render_scrollbar<H: ScrollbarHandle + Clone>(
|
||||
// Do not render scrollbar when inspector is picking elements,
|
||||
// to allow us to pick the background elements.
|
||||
let is_inspector_picking = window.is_inspector_picking(cx);
|
||||
|
||||
if is_inspector_picking {
|
||||
return div();
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{
|
||||
fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner,
|
||||
CursorStyle, Edges, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla,
|
||||
InspectorElementId, IntoElement, IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent,
|
||||
MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style,
|
||||
UniformListScrollHandle, Window,
|
||||
App, Axis, BorderStyle, Bounds, ContentMask, Corner, CursorStyle, Edges, Element, ElementId,
|
||||
GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, IsZero,
|
||||
LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
|
||||
Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill,
|
||||
point, px, relative, size,
|
||||
};
|
||||
use theme::{ActiveTheme, ScrollbarMode};
|
||||
|
||||
@@ -407,7 +407,6 @@ impl Scrollbar {
|
||||
ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
|
||||
_ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
|
||||
};
|
||||
|
||||
(
|
||||
cx.theme().scrollbar_thumb_background,
|
||||
cx.theme().scrollbar_track_background,
|
||||
@@ -522,6 +521,7 @@ impl Element for Scrollbar {
|
||||
|
||||
let mut states = vec![];
|
||||
let mut has_both = self.axis.is_both();
|
||||
|
||||
let scroll_size = self
|
||||
.scroll_size
|
||||
.unwrap_or(self.scroll_handle.content_size());
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
edition = "2024"
|
||||
style_edition = "2024"
|
||||
tab_spaces = 4
|
||||
newline_style = "Auto"
|
||||
reorder_imports = true
|
||||
|
||||
Reference in New Issue
Block a user