chore: Refine the UI (#102)

* update deps

* update window options

* linux title bar

* fix build

* .

* fix build

* rounded corners on linux

* .

* .

* fix i18n key

* fix change subject modal

* .

* update new account

* .

* update relay modal

* .

* fix i18n keys

---------

Co-authored-by: reya <reya@macbook.local>
This commit is contained in:
reya
2025-08-02 11:37:15 +07:00
committed by GitHub
parent 3cf9dde882
commit c188f12993
43 changed files with 2552 additions and 1790 deletions

276
Cargo.lock generated
View File

@@ -39,6 +39,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ahash"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0453232ace82dee0dd0b4c87a59bd90f7b53b314f3e0f61fe2ee7c8a16482289"
[[package]]
name = "ahash"
version = "0.8.12"
@@ -864,9 +870,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.30"
version = "1.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7"
checksum = "c3a42d84bb6b69d3a8b3eaacf0d88f179e1929695e1ad012b6cf64d9caaa5fd2"
dependencies = [
"jobserver",
"libc",
@@ -1083,7 +1089,7 @@ dependencies = [
[[package]]
name = "collections"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2"
source = "git+https://github.com/zed-industries/zed#4d79edc7533d6d0a8e29004c789bc6041a97993d"
dependencies = [
"indexmap",
"rustc-hash 2.1.1",
@@ -1203,6 +1209,7 @@ dependencies = [
"smallvec",
"smol",
"theme",
"title_bar",
"tracing-subscriber",
"ui",
]
@@ -1436,9 +1443,9 @@ dependencies = [
[[package]]
name = "ctor"
version = "0.4.2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4735f265ba6a1188052ca32d461028a7d1125868be18e287e756019da7607b5"
checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6"
dependencies = [
"ctor-proc-macro",
"dtor",
@@ -1446,9 +1453,9 @@ dependencies = [
[[package]]
name = "ctor-proc-macro"
version = "0.0.5"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f211af61d8efdd104f96e57adf5e426ba1bc3ed7a4ead616e15e5881fd79c4d"
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
[[package]]
name = "data-encoding"
@@ -1484,7 +1491,7 @@ dependencies = [
[[package]]
name = "derive_refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2"
source = "git+https://github.com/zed-industries/zed#4d79edc7533d6d0a8e29004c789bc6041a97993d"
dependencies = [
"proc-macro2",
"quote",
@@ -1580,6 +1587,15 @@ dependencies = [
"libloading",
]
[[package]]
name = "dlv-list"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68df3f2b690c1b86e65ef7830956aededf3cb0a16f898f79b9a6f421a7b6211b"
dependencies = [
"rand 0.8.5",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
@@ -1630,9 +1646,9 @@ dependencies = [
[[package]]
name = "dyn-clone"
version = "1.0.19"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
@@ -1649,7 +1665,7 @@ dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 0.9.2",
"toml 0.9.4",
"vswhom",
"winreg",
]
@@ -1824,6 +1840,15 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "file-locker"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6c3e69656680c6c3d76750b46dfa64bf07626bd2130c540d6cf2d306ba595a8"
dependencies = [
"nix 0.29.0",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -1947,7 +1972,7 @@ checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"memmap2 0.9.7",
"slotmap",
"tinyvec",
"ttf-parser 0.20.0",
@@ -1961,7 +1986,7 @@ checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"memmap2 0.9.7",
"slotmap",
"tinyvec",
"ttf-parser 0.25.1",
@@ -2018,6 +2043,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "freedesktop_entry_parser"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db9c27b72f19a99a895f8ca89e2d26e4ef31013376e56fdafef697627306c3e4"
dependencies = [
"nom",
"thiserror 1.0.69",
]
[[package]]
name = "freetype-sys"
version = "0.20.1"
@@ -2336,7 +2371,7 @@ dependencies = [
[[package]]
name = "gpui"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2"
source = "git+https://github.com/zed-industries/zed#4d79edc7533d6d0a8e29004c789bc6041a97993d"
dependencies = [
"anyhow",
"as-raw-xcb-connection",
@@ -2429,7 +2464,7 @@ dependencies = [
[[package]]
name = "gpui_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2"
source = "git+https://github.com/zed-industries/zed#4d79edc7533d6d0a8e29004c789bc6041a97993d"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -2441,7 +2476,7 @@ dependencies = [
[[package]]
name = "gpui_tokio"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2"
source = "git+https://github.com/zed-industries/zed#4d79edc7533d6d0a8e29004c789bc6041a97993d"
dependencies = [
"gpui",
"tokio",
@@ -2485,6 +2520,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "hashbrown"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04"
dependencies = [
"ahash 0.4.8",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -2663,7 +2707,7 @@ dependencies = [
[[package]]
name = "http_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2"
source = "git+https://github.com/zed-industries/zed#4d79edc7533d6d0a8e29004c789bc6041a97993d"
dependencies = [
"anyhow",
"bytes",
@@ -2672,6 +2716,7 @@ dependencies = [
"http",
"http-body",
"log",
"parking_lot",
"serde",
"serde_json",
"url",
@@ -2681,7 +2726,7 @@ dependencies = [
[[package]]
name = "http_client_tls"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2"
source = "git+https://github.com/zed-industries/zed#4d79edc7533d6d0a8e29004c789bc6041a97993d"
dependencies = [
"rustls",
"rustls-platform-verifier",
@@ -3271,7 +3316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667"
dependencies = [
"cfg-if",
"windows-targets 0.53.2",
"windows-targets 0.53.3",
]
[[package]]
@@ -3282,14 +3327,37 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libredox"
version = "0.1.6"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0"
checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3"
dependencies = [
"bitflags 2.9.1",
"libc",
]
[[package]]
name = "linicon"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ee8c5653188a809616c97296180a0547a61dba205bcdcbdd261dbd022a25fd9"
dependencies = [
"file-locker",
"freedesktop_entry_parser",
"linicon-theme",
"memmap2 0.5.10",
"thiserror 1.0.69",
]
[[package]]
name = "linicon-theme"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4f8240c33bb08c5d8b8cdea87b683b05e61037aa76ff26bef40672cc6ecbb80"
dependencies = [
"freedesktop_entry_parser",
"rust-ini",
]
[[package]]
name = "linkify"
version = "0.10.0"
@@ -3359,9 +3427,9 @@ dependencies = [
[[package]]
name = "lru"
version = "0.14.0"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f8cc7106155f10bdf99a6f379688f543ad6596a415375b36a59a054ceda1198"
checksum = "86ea4e65087ff52f3862caff188d489f1fab49a0cb09e01b2e3f1a617b10aaed"
[[package]]
name = "lru-slab"
@@ -3459,7 +3527,7 @@ dependencies = [
[[package]]
name = "media"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2"
source = "git+https://github.com/zed-industries/zed#4d79edc7533d6d0a8e29004c789bc6041a97993d"
dependencies = [
"anyhow",
"bindgen 0.71.1",
@@ -3478,6 +3546,15 @@ version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "memmap2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327"
dependencies = [
"libc",
]
[[package]]
name = "memmap2"
version = "0.9.7"
@@ -3681,8 +3758,8 @@ dependencies = [
[[package]]
name = "nostr"
version = "0.42.1"
source = "git+https://github.com/rust-nostr/nostr#dd8328ded8958c8c1133b293142da94c3e1d6f70"
version = "0.43.0"
source = "git+https://github.com/rust-nostr/nostr#9d91709c35f743361d68c16349d33979329ebd84"
dependencies = [
"aes",
"base64",
@@ -3704,8 +3781,8 @@ dependencies = [
[[package]]
name = "nostr-connect"
version = "0.42.0"
source = "git+https://github.com/rust-nostr/nostr#dd8328ded8958c8c1133b293142da94c3e1d6f70"
version = "0.43.0"
source = "git+https://github.com/rust-nostr/nostr#9d91709c35f743361d68c16349d33979329ebd84"
dependencies = [
"async-utility",
"nostr",
@@ -3716,8 +3793,8 @@ dependencies = [
[[package]]
name = "nostr-database"
version = "0.42.0"
source = "git+https://github.com/rust-nostr/nostr#dd8328ded8958c8c1133b293142da94c3e1d6f70"
version = "0.43.0"
source = "git+https://github.com/rust-nostr/nostr#9d91709c35f743361d68c16349d33979329ebd84"
dependencies = [
"flatbuffers",
"lru",
@@ -3727,10 +3804,11 @@ dependencies = [
[[package]]
name = "nostr-lmdb"
version = "0.42.0"
source = "git+https://github.com/rust-nostr/nostr#dd8328ded8958c8c1133b293142da94c3e1d6f70"
version = "0.43.0"
source = "git+https://github.com/rust-nostr/nostr#9d91709c35f743361d68c16349d33979329ebd84"
dependencies = [
"async-utility",
"flume",
"heed",
"nostr",
"nostr-database",
@@ -3740,8 +3818,8 @@ dependencies = [
[[package]]
name = "nostr-relay-pool"
version = "0.42.0"
source = "git+https://github.com/rust-nostr/nostr#dd8328ded8958c8c1133b293142da94c3e1d6f70"
version = "0.43.0"
source = "git+https://github.com/rust-nostr/nostr#9d91709c35f743361d68c16349d33979329ebd84"
dependencies = [
"async-utility",
"async-wsocket",
@@ -3756,8 +3834,8 @@ dependencies = [
[[package]]
name = "nostr-sdk"
version = "0.42.0"
source = "git+https://github.com/rust-nostr/nostr#dd8328ded8958c8c1133b293142da94c3e1d6f70"
version = "0.43.0"
source = "git+https://github.com/rust-nostr/nostr#9d91709c35f743361d68c16349d33979329ebd84"
dependencies = [
"async-utility",
"nostr",
@@ -4149,6 +4227,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c672c7ad9ec066e428c00eb917124a06f08db19e2584de982cc34b1f4c12485"
dependencies = [
"dlv-list",
"hashbrown 0.9.1",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -4666,9 +4754,9 @@ dependencies = [
[[package]]
name = "rangemap"
version = "1.5.1"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60fcc7d6849342eff22c4350c8b9a989ee8ceabc4b481253e8946b9fe83d684"
checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223"
[[package]]
name = "rav1e"
@@ -4770,9 +4858,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.5.15"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec"
checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77"
dependencies = [
"bitflags 2.9.1",
]
@@ -4811,7 +4899,7 @@ dependencies = [
[[package]]
name = "refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2"
source = "git+https://github.com/zed-industries/zed#4d79edc7533d6d0a8e29004c789bc6041a97993d"
dependencies = [
"derive_refineable",
"workspace-hack",
@@ -4963,7 +5051,7 @@ dependencies = [
[[package]]
name = "reqwest_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2"
source = "git+https://github.com/zed-industries/zed#4d79edc7533d6d0a8e29004c789bc6041a97993d"
dependencies = [
"anyhow",
"bytes",
@@ -5112,10 +5200,20 @@ dependencies = [
]
[[package]]
name = "rustc-demangle"
version = "0.1.25"
name = "rust-ini"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
checksum = "63471c4aa97a1cf8332a5f97709a79a4234698de6a1f5087faf66f2dae810e22"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rustc-demangle"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
[[package]]
name = "rustc-hash"
@@ -5166,9 +5264,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.29"
version = "0.23.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1"
checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc"
dependencies = [
"aws-lc-rs",
"log",
@@ -5489,7 +5587,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749"
[[package]]
name = "semantic_version"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2"
source = "git+https://github.com/zed-industries/zed#4d79edc7533d6d0a8e29004c789bc6041a97993d"
dependencies = [
"anyhow",
"serde",
@@ -5544,9 +5642,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.141"
version = "1.0.142"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3"
checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7"
dependencies = [
"indexmap",
"itoa",
@@ -5884,7 +5982,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "sum_tree"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2"
source = "git+https://github.com/zed-industries/zed#4d79edc7533d6d0a8e29004c789bc6041a97993d"
dependencies = [
"arrayvec",
"log",
@@ -6303,11 +6401,30 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "title_bar"
version = "1.0.0"
dependencies = [
"anyhow",
"common",
"gpui",
"i18n",
"linicon",
"log",
"nostr-sdk",
"rust-i18n",
"smallvec",
"smol",
"theme",
"ui",
"windows 0.61.3",
]
[[package]]
name = "tokio"
version = "1.46.1"
version = "1.47.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17"
checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
dependencies = [
"backtrace",
"bytes",
@@ -6316,9 +6433,9 @@ dependencies = [
"mio",
"pin-project-lite",
"slab",
"socket2 0.5.10",
"socket2 0.6.0",
"tokio-macros",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -6407,9 +6524,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.2"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac"
checksum = "41ae868b5a0f67631c14589f7e250c1ea2c574ee5ba21c6c8dd4b1485705a5a1"
dependencies = [
"indexmap",
"serde",
@@ -6857,7 +6974,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "util"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2"
source = "git+https://github.com/zed-industries/zed#4d79edc7533d6d0a8e29004c789bc6041a97993d"
dependencies = [
"anyhow",
"async-fs",
@@ -7125,13 +7242,13 @@ dependencies = [
[[package]]
name = "wayland-backend"
version = "0.3.10"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe770181423e5fc79d3e2a7f4410b7799d5aab1de4372853de3c6aa13ca24121"
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
dependencies = [
"cc",
"downcast-rs",
"rustix 0.38.44",
"rustix 1.0.8",
"scoped-tls",
"smallvec",
"wayland-sys",
@@ -7139,23 +7256,23 @@ dependencies = [
[[package]]
name = "wayland-client"
version = "0.31.10"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61"
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
dependencies = [
"bitflags 2.9.1",
"rustix 0.38.44",
"rustix 1.0.8",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-cursor"
version = "0.31.10"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a65317158dec28d00416cb16705934070aef4f8393353d41126c54264ae0f182"
checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29"
dependencies = [
"rustix 0.38.44",
"rustix 1.0.8",
"wayland-client",
"xcursor",
]
@@ -7187,9 +7304,9 @@ dependencies = [
[[package]]
name = "wayland-scanner"
version = "0.31.6"
version = "0.31.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484"
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
dependencies = [
"proc-macro2",
"quick-xml 0.37.5",
@@ -7198,9 +7315,9 @@ dependencies = [
[[package]]
name = "wayland-sys"
version = "0.31.6"
version = "0.31.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615"
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
dependencies = [
"dlib",
"log",
@@ -7489,7 +7606,7 @@ checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
dependencies = [
"windows-result 0.3.4",
"windows-strings 0.3.1",
"windows-targets 0.53.2",
"windows-targets 0.53.3",
]
[[package]]
@@ -7581,7 +7698,7 @@ version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.2",
"windows-targets 0.53.3",
]
[[package]]
@@ -7632,10 +7749,11 @@ dependencies = [
[[package]]
name = "windows-targets"
version = "0.53.2"
version = "0.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef"
checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0",
@@ -7946,7 +8064,7 @@ name = "xim"
version = "0.4.0"
source = "git+https://github.com/XDeme1/xim-rs?rev=d50d461764c2213655cd9cf65a0ea94c70d3c4fd#d50d461764c2213655cd9cf65a0ea94c70d3c4fd"
dependencies = [
"ahash",
"ahash 0.8.12",
"hashbrown 0.14.5",
"log",
"x11rb",
@@ -7978,7 +8096,7 @@ checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9"
dependencies = [
"as-raw-xcb-connection",
"libc",
"memmap2",
"memmap2 0.9.7",
"xkeysym",
]
@@ -8212,9 +8330,9 @@ dependencies = [
[[package]]
name = "zune-jpeg"
version = "0.4.19"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9e525af0a6a658e031e95f14b7f889976b74a11ba0eca5a5fc9ac8a1c43a6a"
checksum = "fc1f7e205ce79eb2da3cd71c5f55f3589785cb7c79f6a03d1c8d1491bda5d089"
dependencies = [
"zune-core",
]

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20 9-6.586 6.586a2 2 0 0 1-2.828 0L4 9"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m8 10 3.293 3.293a1 1 0 0 0 1.414 0L16 10"/>
</svg>

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 247 B

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3.75V12m0 0v8.25M12 12H3.75M12 12h8.25"/>
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 4v8m0 0v8m0-8H4m8 0h8"/>
</svg>

Before

Width:  |  Height:  |  Size: 248 B

After

Width:  |  Height:  |  Size: 205 B

View File

@@ -72,7 +72,11 @@ pub async fn nip96_upload(
let json: Value = res.json().await?;
let config = nip96::ServerConfig::from_json(json.to_string())?;
let signer = client.signer().await?;
let signer = if client.has_signer().await {
client.signer().await?
} else {
Keys::generate().into_nostr_signer()
};
let url = upload(&signer, &config, file, None).await?;

View File

@@ -11,6 +11,7 @@ path = "src/main.rs"
[dependencies]
assets = { path = "../assets" }
ui = { path = "../ui" }
title_bar = { path = "../title_bar" }
identity = { path = "../identity" }
theme = { path = "../theme" }
common = { path = "../common" }

View File

@@ -1,33 +1,39 @@
use std::sync::Arc;
use anyhow::Error;
use client_keys::ClientKeys;
use global::constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH};
use global::nostr_client;
use common::display::DisplayProfile;
use global::constants::DEFAULT_SIDEBAR_WIDTH;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, relative, Action, App, AppContext, Axis, Context, Entity, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window,
actions, div, px, rems, Action, App, AppContext, Axis, Context, Entity, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use i18n::t;
use identity::Identity;
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use registry::{Registry, RoomEmitter};
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use theme::{ActiveTheme, Theme, ThemeMode};
use title_bar::TitleBar;
use ui::actions::OpenProfile;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::dock::DockPlacement;
use ui::dock_area::panel::PanelView;
use ui::dock_area::{ClosePanel, DockArea, DockItem};
use ui::modal::ModalButtonProps;
use ui::{ContextModal, IconName, Root, Sizable, StyledExt, TitleBar};
use ui::popup_menu::PopupMenuExt;
use ui::{h_flex, ContextModal, IconName, Root, Sizable, StyledExt};
use crate::views::compose::compose_button;
use crate::views::screening::Screening;
use crate::views::user_profile::UserProfile;
use crate::views::{
chat, login, new_account, onboarding, preferences, sidebar, startup, user_profile, welcome,
backup_keys, chat, login, messaging_relays, new_account, onboarding, preferences, sidebar,
startup, user_profile, welcome,
};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
@@ -44,6 +50,8 @@ pub fn new_account(window: &mut Window, cx: &mut App) {
ChatSpace::set_center_panel(panel, window, cx);
}
actions!(user, [DarkMode, Settings, Logout]);
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub enum PanelKind {
Room(u64),
@@ -70,14 +78,15 @@ pub struct ToggleModal {
}
pub struct ChatSpace {
title_bar: Entity<TitleBar>,
dock: Entity<DockArea>,
toolbar: bool,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 5]>,
}
impl ChatSpace {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let title_bar = cx.new(|_| TitleBar::new());
let dock = cx.new(|cx| {
let panel = Arc::new(startup::init(window, cx));
let center = DockItem::panel(panel);
@@ -99,14 +108,17 @@ impl ChatSpace {
window,
|_this: &mut Self, state, window, cx| {
if !state.read(cx).has_keys() {
window.open_modal(cx, |this, _window, cx| {
let title = SharedString::new(t!("startup.client_keys_warning"));
let desc = SharedString::new(t!("startup.client_keys_desc"));
window.open_modal(cx, move |this, _window, cx| {
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.button_props(
ModalButtonProps::default()
.cancel_text(t!("chatspace.create_new_keys"))
.cancel_text(t!("startup.create_new_keys"))
.ok_text(t!("common.allow")),
)
.child(
@@ -124,13 +136,9 @@ impl ChatSpace {
div()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("chatspace.warning"))),
.child(title.clone()),
)
.child(div().line_height(relative(1.4)).child(
SharedString::new(t!(
"chatspace.allow_keychain_access"
)),
)),
.child(desc.clone()),
)
.on_cancel(|_, _window, cx| {
ClientKeys::global(cx).update(cx, |this, cx| {
@@ -157,21 +165,21 @@ impl ChatSpace {
window,
|this: &mut Self, state, window, cx| {
if !state.read(cx).has_signer() {
this.open_onboarding(window, cx);
this.set_onboarding_panels(window, cx);
} else {
this.open_chats(window, cx);
this.set_chat_panels(window, cx);
}
},
));
// Automatically run on_load function when UserProfile is created
// Automatically run load function when UserProfile is created
subscriptions.push(cx.observe_new::<UserProfile>(|this, window, cx| {
if let Some(window) = window {
this.on_load(window, cx);
this.load(window, cx);
}
}));
// Automatically run on_load function when Screening is created
// Automatically run load function when Screening is created
subscriptions.push(cx.observe_new::<Screening>(|this, window, cx| {
if let Some(window) = window {
this.load(window, cx);
@@ -196,7 +204,7 @@ impl ChatSpace {
this.add_panel(panel, DockPlacement::Center, window, cx);
});
} else {
window.push_notification(t!("chatspace.failed_to_open_room"), cx);
window.push_notification(t!("common.room_error"), cx);
}
}
RoomEmitter::Close(..) => {
@@ -216,16 +224,13 @@ impl ChatSpace {
Self {
dock,
title_bar,
subscriptions,
toolbar: false,
}
})
}
pub fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// No active user, disable user's toolbar
self.toolbar(false, cx);
pub fn set_onboarding_panels(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let panel = Arc::new(onboarding::init(window, cx));
let center = DockItem::panel(panel);
@@ -235,17 +240,14 @@ impl ChatSpace {
});
}
pub fn open_chats(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Enable the toolbar for logged in users
self.toolbar(true, cx);
// Load all chat rooms from database
Registry::global(cx).update(cx, |this, cx| {
this.load_rooms(window, cx);
});
pub fn set_chat_panels(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let registry = Registry::global(cx);
let weak_dock = self.dock.downgrade();
// The left panel will render sidebar
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
// The center panel will render chat rooms (as tabs)
let center = DockItem::split_with_sizes(
Axis::Vertical,
vec![DockItem::tabs(
@@ -261,66 +263,31 @@ impl ChatSpace {
cx,
);
// Update dock
self.dock.update(cx, |this, cx| {
this.set_left_dock(left, Some(px(DEFAULT_SIDEBAR_WIDTH)), true, window, cx);
this.set_center(center, window, cx);
});
cx.defer_in(window, |this, window, cx| {
let verify_messaging_relays = this.verify_messaging_relays(cx);
cx.spawn_in(window, async move |_, cx| {
if let Ok(status) = verify_messaging_relays.await {
if !status {
cx.update(|window, cx| {
window.dispatch_action(
Box::new(ToggleModal {
modal: ModalKind::SetupRelay,
}),
cx,
);
})
.ok();
}
}
})
.detach();
// Load all chat rooms from the database
registry.update(cx, |this, cx| {
this.load_rooms(window, cx);
});
}
pub fn open_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let settings = preferences::init(window, cx);
let title = SharedString::new(t!("chatspace.preferences_title"));
pub fn on_settings(&mut self, _ev: &Settings, window: &mut Window, cx: &mut Context<Self>) {
let view = preferences::init(window, cx);
let title = SharedString::new(t!("common.preferences"));
window.open_modal(cx, move |modal, _, _| {
modal
.title(title.clone())
.width(px(DEFAULT_MODAL_WIDTH))
.child(settings.clone())
.width(px(480.))
.child(view.clone())
});
}
fn toolbar(&mut self, status: bool, cx: &mut Context<Self>) {
self.toolbar = status;
cx.notify();
}
fn verify_messaging_relays(&self, cx: &App) -> Task<Result<bool, Error>> {
cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
let is_exist = client.database().query(filter).await?.first().is_some();
Ok(is_exist)
})
}
fn toggle_appearance(&self, window: &mut Window, cx: &mut App) {
fn on_dark_mode(&mut self, _ev: &DarkMode, window: &mut Window, cx: &mut Context<Self>) {
if cx.theme().mode.is_dark() {
Theme::change(ThemeMode::Light, Some(window), cx);
} else {
@@ -328,23 +295,80 @@ impl ChatSpace {
}
}
fn logout(&self, window: &mut Window, cx: &mut App) {
Identity::global(cx).update(cx, |this, cx| {
fn on_sign_out(&mut self, _ev: &Logout, window: &mut Window, cx: &mut Context<Self>) {
let identity = Identity::global(cx);
// TODO: save current session?
identity.update(cx, |this, cx| {
this.unload(window, cx);
});
}
fn on_open_profile(&mut self, a: &OpenProfile, window: &mut Window, cx: &mut Context<Self>) {
let public_key = a.0;
fn on_open_profile(&mut self, ev: &OpenProfile, window: &mut Window, cx: &mut Context<Self>) {
let public_key = ev.0;
let profile = user_profile::init(public_key, window, cx);
window.open_modal(cx, move |this, _window, _cx| {
// user_profile::init(public_key, window, cx)
this.child(profile.clone())
this.alert()
.show_close(true)
.overlay_closable(true)
.child(profile.clone())
.button_props(ModalButtonProps::default().ok_text(t!("profile.njump")))
.on_ok(move |_, _window, cx| {
let Ok(bech32) = public_key.to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}"));
false
})
});
}
pub(crate) fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) {
fn render_titlebar_left_side(
&mut self,
_window: &mut Window,
_cx: &Context<Self>,
) -> impl IntoElement {
let compose_button = compose_button().into_any_element();
h_flex().gap_1().child(compose_button)
}
fn render_titlebar_right_side(
&mut self,
profile: &Profile,
_window: &mut Window,
cx: &Context<Self>,
) -> impl IntoElement {
let proxy = AppSettings::get_proxy_user_avatars(cx);
let need_backup = Identity::read_global(cx).need_backup();
let relay_ready = Identity::read_global(cx).relay_ready();
h_flex()
.gap_1()
.when_some(relay_ready, |this, status| {
this.when(!status, |this| this.child(messaging_relays::relay_button()))
})
.when_some(need_backup, |this, keys| {
this.child(backup_keys::backup_button(keys.to_owned()))
})
.child(
Button::new("user")
.small()
.reverse()
.transparent()
.icon(IconName::CaretDown)
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(1.49)))
.popup_menu(|this, _window, _cx| {
this.menu(t!("user.dark_mode"), Box::new(DarkMode))
.menu(t!("user.settings"), Box::new(Settings))
.separator()
.menu(t!("user.sign_out"), Box::new(Logout))
}),
)
}
pub(crate) fn set_center_panel<P>(panel: P, window: &mut Window, cx: &mut App)
where
P: PanelView,
{
if let Some(Some(root)) = window.root::<Root>() {
if let Ok(chatspace) = root.read(cx).view().clone().downcast::<ChatSpace>() {
let panel = Arc::new(panel);
@@ -365,7 +389,27 @@ impl Render for ChatSpace {
let modal_layer = Root::render_modal_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
// Only render titlebar element if user is logged in
if let Some(identity) = Identity::read_global(cx).public_key() {
let profile = Registry::read_global(cx).get_person(&identity, cx);
let left_side = self
.render_titlebar_left_side(window, cx)
.into_any_element();
let right_side = self
.render_titlebar_right_side(&profile, window, cx)
.into_any_element();
self.title_bar.update(cx, |this, _cx| {
this.set_children(vec![left_side, right_side]);
})
}
div()
.on_action(cx.listener(Self::on_settings))
.on_action(cx.listener(Self::on_dark_mode))
.on_action(cx.listener(Self::on_sign_out))
.on_action(cx.listener(Self::on_open_profile))
.relative()
.size_full()
@@ -375,58 +419,7 @@ impl Render for ChatSpace {
.flex_col()
.size_full()
// Title Bar
.child(
TitleBar::new()
// Left side
.child(div())
// Right side
.when(self.toolbar, |this| {
this.child(
div()
.flex()
.items_center()
.justify_end()
.gap_1p5()
.px_2()
.child(
Button::new("appearance")
.tooltip(t!("chatspace.appearance_tooltip"))
.small()
.ghost()
.map(|this| {
if cx.theme().mode.is_dark() {
this.icon(IconName::Sun)
} else {
this.icon(IconName::Moon)
}
})
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_appearance(window, cx);
})),
)
.child(
Button::new("preferences")
.tooltip(t!("chatspace.preferences_tooltip"))
.small()
.ghost()
.icon(IconName::Settings)
.on_click(cx.listener(|this, _, window, cx| {
this.open_settings(window, cx);
})),
)
.child(
Button::new("logout")
.tooltip(t!("common.logout"))
.small()
.ghost()
.icon(IconName::Logout)
.on_click(cx.listener(|this, _, window, cx| {
this.logout(window, cx);
})),
),
)
}),
)
.child(self.title_bar.clone())
// Dock
.child(self.dock.clone()),
)

View File

@@ -5,21 +5,16 @@ use std::time::Duration;
use anyhow::{anyhow, Error};
use assets::Assets;
use auto_update::AutoUpdater;
#[cfg(not(target_os = "linux"))]
use global::constants::APP_NAME;
use global::constants::{
ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT,
ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, APP_PUBKEY, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT,
METADATA_BATCH_TIMEOUT, NEW_MESSAGE_SUB_ID, SEARCH_RELAYS,
};
use global::{nostr_client, NostrSignal};
use gpui::{
actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
WindowBounds, WindowKind, WindowOptions,
actions, point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
SharedString, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations,
WindowKind, WindowOptions,
};
#[cfg(not(target_os = "linux"))]
use gpui::{point, SharedString, TitlebarOptions};
#[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations};
use identity::Identity;
use nostr_sdk::prelude::*;
use registry::Registry;
@@ -138,7 +133,7 @@ fn main() {
continue;
}
let duration = smol::Timer::after(Duration::from_secs(75));
let duration = smol::Timer::after(Duration::from_secs(30));
let recv = || async {
// prevent inline format
@@ -202,25 +197,22 @@ fn main() {
items: vec![MenuItem::action("Quit", Quit)],
}]);
// Set up the window bounds
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
// Set up the window options
let opts = WindowOptions {
#[cfg(not(target_os = "linux"))]
window_background: WindowBackgroundAppearance::Opaque,
window_decorations: Some(WindowDecorations::Client),
window_bounds: Some(WindowBounds::Windowed(bounds)),
window_min_size: Some(size(px(800.0), px(600.0))),
kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(APP_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(920.0), px(700.0)),
cx,
))),
#[cfg(target_os = "linux")]
window_background: WindowBackgroundAppearance::Transparent,
#[cfg(target_os = "linux")]
window_decorations: Some(WindowDecorations::Client),
kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
..Default::default()
};

View File

@@ -0,0 +1,258 @@
use std::fs;
use std::time::Duration;
use dirs::document_dir;
use gpui::prelude::FluentBuilder;
use gpui::{
div, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement, Render,
SharedString, Styled, Window,
};
use i18n::{shared_t, t};
use identity::Identity;
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::{divider, h_flex, v_flex, ContextModal, Disableable, IconName, Sizable};
pub fn backup_button(keys: Keys) -> impl IntoElement {
div().child(
Button::new("backup")
.icon(IconName::Info)
.label(t!("new_account.backup_label"))
.danger()
.xsmall()
.rounded(ButtonRounded::Full)
.on_click(move |_, window, cx| {
let title = SharedString::new(t!("new_account.backup_label"));
let keys = keys.clone();
let view = cx.new(|cx| BackupKeys::new(&keys, window, cx));
let weak_view = view.downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
modal
.confirm()
.title(title.clone())
.child(view.clone())
.button_props(
ModalButtonProps::default()
.cancel_text(t!("new_account.backup_skip"))
.ok_text(t!("new_account.backup_download")),
)
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
this.download(window, cx);
})
.ok();
// true to close the modal
false
})
})
}),
)
}
pub struct BackupKeys {
password: Entity<InputState>,
pubkey_input: Entity<InputState>,
secret_input: Entity<InputState>,
error: Option<SharedString>,
copied: bool,
}
impl BackupKeys {
pub fn new(keys: &Keys, window: &mut Window, cx: &mut Context<'_, Self>) -> Self {
let Ok(npub) = keys.public_key.to_bech32();
let Ok(nsec) = keys.secret_key().to_bech32();
let password = cx.new(|cx| InputState::new(window, cx).masked(true));
let pubkey_input = cx.new(|cx| {
InputState::new(window, cx)
.disabled(true)
.default_value(npub)
});
let secret_input = cx.new(|cx| {
InputState::new(window, cx)
.disabled(true)
.default_value(nsec)
});
Self {
password,
pubkey_input,
secret_input,
error: None,
copied: false,
}
}
fn copy_secret(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let item = ClipboardItem::new_string(self.secret_input.read(cx).value().to_string());
cx.write_to_clipboard(item);
self.set_copied(true, window, cx);
}
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
// Reset the copied state after a delay
if status {
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_copied(false, window, cx);
})
.ok();
})
.ok();
})
.detach();
}
}
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
where
E: Into<SharedString>,
{
self.error = Some(error.into());
cx.notify();
// Clear the error message after a delay
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})
.ok();
})
.ok();
})
.detach();
}
fn download(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let document_dir = document_dir().expect("Failed to get document directory");
let password = self.password.read(cx).value().to_string();
if password.is_empty() {
self.set_error(t!("login.password_is_required"), window, cx);
return;
};
let path = cx.prompt_for_new_path(&document_dir);
let nsec = self.secret_input.read(cx).value().to_string();
cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(path.await.map_err(|e| e.into())) {
Ok(Ok(Some(path))) => {
cx.update(|window, cx| {
match fs::write(&path, nsec) {
Ok(_) => {
Identity::global(cx).update(cx, |this, cx| {
this.clear_need_backup(password, cx);
});
// Close the current modal
window.close_modal(cx);
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), window, cx);
})
.ok();
}
};
})
.ok();
}
_ => {
log::error!("Failed to save backup keys");
}
};
})
.detach();
}
}
impl Render for BackupKeys {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.text_sm()
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("new_account.backup_description")),
)
.child(
v_flex()
.gap_1()
.child(shared_t!("common.pubkey"))
.child(TextInput::new(&self.pubkey_input).small())
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(shared_t!("new_account.backup_pubkey_note")),
),
)
.child(divider(cx))
.child(
v_flex()
.gap_1()
.child(shared_t!("common.secret"))
.child(
h_flex()
.gap_1()
.child(TextInput::new(&self.secret_input).small())
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircleFill
} else {
IconName::Copy
}
})
.ghost()
.disabled(self.copied)
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy_secret(window, cx);
})),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(shared_t!("new_account.backup_secret_note")),
),
)
.child(divider(cx))
.child(
v_flex()
.gap_1()
.child(shared_t!("login.set_password"))
.child(TextInput::new(&self.password).small())
.when_some(self.error.as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
)
}
}

View File

@@ -33,6 +33,7 @@ use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::emoji_picker::EmojiPicker;
use ui::input::{InputEvent, InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::notification::Notification;
use ui::popup_menu::PopupMenu;
use ui::text::RichText;
@@ -813,7 +814,7 @@ impl Panel for Chat {
}
fn toolbar_buttons(&self, _window: &Window, cx: &App) -> Vec<Button> {
let id = self.room.read(cx).id;
let room = self.room.downgrade();
let subject = self
.room
.read(cx)
@@ -825,11 +826,31 @@ impl Panel for Chat {
.icon(IconName::EditFill)
.tooltip(t!("chat.change_subject_button"))
.on_click(move |_, window, cx| {
let subject = subject::init(id, subject.clone(), window, cx);
let view = subject::init(subject.clone(), window, cx);
let room = room.clone();
let weak_view = view.downgrade();
window.open_modal(cx, move |this, _window, _cx| {
this.title(SharedString::new(t!("chat.change_subject_modal_title")))
.child(subject.clone())
let room = room.clone();
let weak_view = weak_view.clone();
this.confirm()
.title(SharedString::new(t!("chat.change_subject_modal_title")))
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.change")))
.on_ok(move |_, _window, cx| {
if let Ok(subject) =
weak_view.read_with(cx, |this, cx| this.new_subject(cx))
{
room.update(cx, |this, cx| {
this.subject = Some(subject);
cx.notify();
})
.ok();
}
// true to close the modal
true
})
});
});
@@ -891,8 +912,9 @@ impl Render for Chat {
.text_color(cx.theme().text_muted)
.child(
Button::new("upload")
.icon(Icon::new(IconName::Upload))
.icon(IconName::Upload)
.ghost()
.large()
.disabled(self.uploading)
.loading(self.uploading)
.on_click(cx.listener(
@@ -903,7 +925,8 @@ impl Render for Chat {
)
.child(
EmojiPicker::new(self.input.downgrade())
.icon(IconName::EmojiFill),
.icon(IconName::EmojiFill)
.large(),
),
)
.child(TextInput::new(&self.input)),

View File

@@ -3,92 +3,53 @@ use gpui::{
Styled, Window,
};
use i18n::t;
use registry::Registry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::{ContextModal, Sizable};
use ui::{v_flex, Sizable};
pub fn init(
id: u64,
subject: Option<String>,
window: &mut Window,
cx: &mut App,
) -> Entity<Subject> {
Subject::new(id, subject, window, cx)
pub fn init(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Subject> {
Subject::new(subject, window, cx)
}
pub struct Subject {
id: u64,
input: Entity<InputState>,
}
impl Subject {
pub fn new(
id: u64,
subject: Option<String>,
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
pub fn new(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Self> {
let input = cx.new(|cx| {
let mut this = InputState::new(window, cx).placeholder(t!("subject.placeholder"));
if let Some(text) = subject.clone() {
if let Some(text) = subject.as_ref() {
this.set_value(text, window, cx);
}
this
});
cx.new(|_| Self { id, input })
cx.new(|_| Self { input })
}
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let registry = Registry::global(cx).read(cx);
let subject = self.input.read(cx).value().clone();
if let Some(room) = registry.room(&self.id, cx) {
room.update(cx, |this, cx| {
this.subject = Some(subject);
cx.notify();
});
window.close_modal(cx);
} else {
window.push_notification(SharedString::new(t!("subject.room_not_found")), cx);
}
pub fn new_subject(&self, cx: &App) -> SharedString {
self.input.read(cx).value().clone()
}
}
impl Render for Subject {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_3()
v_flex()
.gap_1()
.child(
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("subject.title"))),
)
.child(TextInput::new(&self.input).small())
.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(SharedString::new(t!("subject.help_text"))),
),
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("subject.title"))),
)
.child(TextInput::new(&self.input).small())
.child(
Button::new("submit")
.label(t!("common.change"))
.primary()
.w_full()
.on_click(cx.listener(|this, _, window, cx| this.update(window, cx))),
div()
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(SharedString::new(t!("subject.help_text"))),
)
}
}

View File

@@ -8,9 +8,9 @@ use global::constants::BOOTSTRAP_RELAYS;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, red, relative, uniform_list, App, AppContext, Context, Entity,
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Subscription, Task, TextAlign, Window,
div, px, relative, rems, uniform_list, AppContext, Context, Entity, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
Subscription, Task, Window,
};
use i18n::t;
use itertools::Itertools;
@@ -21,13 +21,29 @@ use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use smol::Timer;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
use ui::{h_flex, v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Compose> {
cx.new(|cx| Compose::new(window, cx))
pub fn compose_button() -> impl IntoElement {
div().child(
Button::new("compose")
.icon(IconName::Plus)
.ghost_alt()
.cta()
.small()
.rounded(ButtonRounded::Full)
.on_click(move |_, window, cx| {
let compose = cx.new(|cx| Compose::new(window, cx));
let title = SharedString::new(t!("sidebar.direct_messages"));
window.open_modal(cx, move |modal, _window, _cx| {
modal.title(title.clone()).child(compose.clone())
})
}),
)
}
#[derive(Debug)]
@@ -147,13 +163,13 @@ impl Compose {
Ok(())
}
pub fn compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let public_keys: Vec<PublicKey> = self.selected(cx);
if public_keys.is_empty() {
self.set_error(Some(t!("compose.receiver_required").into()), cx);
return;
}
};
// Show loading spinner
self.set_submitting(true, cx);
@@ -169,7 +185,7 @@ impl Compose {
));
}
let event: Task<Result<Room, anyhow::Error>> = cx.background_spawn(async move {
let event: Task<Result<Room, Error>> = cx.background_spawn(async move {
let signer = nostr_client().signer().await?;
let public_key = signer.get_public_key().await?;
@@ -187,15 +203,17 @@ impl Compose {
match event.await {
Ok(room) => {
cx.update(|window, cx| {
let registry = Registry::global(cx);
// Reset local state
this.update(cx, |this, cx| {
this.set_submitting(false, cx);
})
.ok();
Registry::global(cx).update(cx, |this, cx| {
// Create and insert the new room into the registry
registry.update(cx, |this, cx| {
this.push_room(cx.new(|_| room), cx);
});
// Close the current modal
window.close_modal(cx);
})
.ok();
@@ -371,22 +389,20 @@ impl Compose {
let selected = entity.read(cx).select;
items.push(
div()
h_flex()
.id(ix)
.px_1()
.h_9()
.w_full()
.h_11()
.py_1()
.px_3()
.flex()
.items_center()
.justify_between()
.rounded(cx.theme().radius)
.child(
div()
.flex()
.items_center()
.gap_3()
.gap_1p5()
.text_sm()
.child(img(profile.avatar_url(proxy)).size_7().flex_shrink_0())
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(1.75)))
.child(profile.display_name()),
)
.when(selected, |this| {
@@ -414,51 +430,52 @@ impl Render for Compose {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let label = if self.submitting {
t!("compose.creating_dm_button")
} else if self.contacts.len() > 1 {
} else if self.selected(cx).len() > 1 {
t!("compose.create_group_dm_button")
} else {
t!("compose.create_dm_button")
};
let error = self.error_message.read(cx).as_ref();
v_flex()
.gap_1()
.mb_4()
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("compose.description"))),
)
.when_some(self.error_message.read(cx).as_ref(), |this, msg| {
this.child(div().text_xs().text_color(red()).child(msg.clone()))
.when_some(error, |this, msg| {
this.child(
div()
.italic()
.text_sm()
.text_color(cx.theme().danger_foreground)
.child(msg.clone()),
)
})
.child(
div().flex().flex_col().child(
div()
.h_10()
.border_b_1()
.border_color(cx.theme().border)
.flex()
.items_center()
.gap_1()
.child(
div()
.text_sm()
.font_semibold()
.child(SharedString::new(t!("compose.subject_label"))),
)
.child(TextInput::new(&self.title_input).small().appearance(false)),
),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.mt_1()
h_flex()
.gap_1()
.h_10()
.border_b_1()
.border_color(cx.theme().border)
.child(
div()
.flex()
.flex_col()
.text_sm()
.font_semibold()
.child(SharedString::new(t!("compose.subject_label"))),
)
.child(TextInput::new(&self.title_input).small().appearance(false)),
)
.child(
v_flex()
.my_1()
.gap_2()
.child(
v_flex()
.gap_2()
.child(
div()
@@ -467,9 +484,7 @@ impl Render for Compose {
.child(SharedString::new(t!("compose.to_label"))),
)
.child(
div()
.flex()
.items_center()
h_flex()
.gap_1()
.child(
TextInput::new(&self.user_input)
@@ -479,8 +494,8 @@ impl Render for Compose {
.child(
Button::new("add")
.icon(IconName::PlusCircleFill)
.small()
.ghost()
.loading(self.adding)
.disabled(self.adding)
.on_click(cx.listener(move |this, _, window, cx| {
this.add_and_select_contact(window, cx);
@@ -491,14 +506,12 @@ impl Render for Compose {
.map(|this| {
if self.contacts.is_empty() {
this.child(
div()
.w_full()
v_flex()
.h_24()
.flex()
.flex_col()
.w_full()
.items_center()
.justify_center()
.text_align(TextAlign::Center)
.text_center()
.child(
div()
.text_xs()
@@ -525,7 +538,7 @@ impl Render for Compose {
this.list_items(range, cx)
}),
)
.min_h(px(280.)),
.min_h(px(300.)),
)
}
}),
@@ -534,11 +547,12 @@ impl Render for Compose {
Button::new("create_dm_btn")
.label(label)
.primary()
.small()
.w_full()
.loading(self.submitting)
.disabled(self.submitting || self.adding)
.on_click(cx.listener(move |this, _event, window, cx| {
this.compose(window, cx);
this.submit(window, cx);
})),
)
}

View File

@@ -15,7 +15,7 @@ use smol::fs;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::{ContextModal, Disableable, IconName, Sizable};
use ui::{v_flex, Disableable, IconName, Sizable};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<EditProfile> {
EditProfile::new(window, cx)
@@ -166,10 +166,7 @@ impl EditProfile {
.detach();
}
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Show loading spinner
self.set_submitting(true, cx);
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Option<Event>, Error>> {
let avatar = self.avatar_input.read(cx).value().to_string();
let name = self.name_input.read(cx).value().to_string();
let bio = self.bio_input.read(cx).value().to_string();
@@ -191,52 +188,25 @@ impl EditProfile {
new_metadata = new_metadata.website(url);
}
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
nostr_client().set_metadata(&new_metadata).await?;
Ok(())
});
cx.background_spawn(async move {
let client = nostr_client();
let output = client.set_metadata(&new_metadata).await?;
let event = client.database().event_by_id(&output.val).await?;
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
window.push_notification(t!("profile.updated_successfully"), cx);
this.update(cx, |this, cx| {
this.set_submitting(false, cx);
})
.ok();
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
};
Ok(event)
})
.detach();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
cx.notify();
}
}
impl Render for EditProfile {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
v_flex()
.gap_3()
.px_3()
.child(
div()
.w_full()
@@ -306,17 +276,5 @@ impl Render for EditProfile {
.child(SharedString::new(t!("profile.label_bio")))
.child(TextInput::new(&self.bio_input).small()),
)
.child(
div().py_3().child(
Button::new("submit")
.label(SharedString::new(t!("common.update")))
.primary()
.disabled(self.is_loading || self.is_submitting)
.loading(self.is_submitting)
.on_click(cx.listener(move |this, _, window, cx| {
this.submit(window, cx);
})),
),
)
}
}

View File

@@ -11,7 +11,7 @@ use gpui::{
EventEmitter, FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
};
use i18n::t;
use i18n::{shared_t, t};
use identity::Identity;
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
@@ -53,8 +53,11 @@ impl Login {
let key_input =
cx.new(|cx| InputState::new(window, cx).placeholder("nsec... or bunker://..."));
let relay_input =
cx.new(|cx| InputState::new(window, cx).default_value(NOSTR_CONNECT_RELAY));
let relay_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(NOSTR_CONNECT_RELAY)
.placeholder(NOSTR_CONNECT_RELAY)
});
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
//
@@ -556,12 +559,12 @@ impl Render for Login {
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::new(t!("login.title"))),
.child(shared_t!("login.title")),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("login.key_description"))),
.child(shared_t!("login.key_description")),
),
)
.child(
@@ -581,13 +584,12 @@ impl Render for Login {
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
let msg = t!("login.approve_message", i = i);
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(SharedString::new(msg)),
.child(shared_t!("login.approve_message", i = i)),
)
})
.when_some(self.error.read(cx).clone(), |this, error| {
@@ -603,89 +605,90 @@ impl Render for Login {
),
)
.child(
div()
.h_full()
.flex_1()
.flex()
.items_center()
.justify_center()
.bg(cx.theme().surface_background)
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_3()
.text_center()
.child(
div()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.2))
.text_color(cx.theme().text)
.child(SharedString::new(t!("login.nostr_connect"))),
)
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("login.scan_qr"))),
),
)
.when_some(self.qr_image.read(cx).clone(), |this, qr| {
this.child(
div().flex_1().p_1().child(
div()
.size_full()
.flex()
.items_center()
.justify_center()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_3()
.text_center()
.child(
div()
.id("")
.mb_2()
.p_2()
.size_72()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.2))
.text_color(cx.theme().text)
.child(shared_t!("login.nostr_connect")),
)
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(shared_t!("login.scan_qr")),
),
)
.when_some(self.qr_image.read(cx).clone(), |this, qr| {
this.child(
div()
.id("")
.mb_2()
.p_2()
.size_72()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.rounded_2xl()
.shadow_md()
.when(cx.theme().mode.is_dark(), |this| {
this.shadow_none()
.border_1()
.border_color(cx.theme().border)
})
.bg(cx.theme().background)
.child(img(qr).h_64())
.on_click(cx.listener(move |this, _, window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
this.connection_string.read(cx).to_string(),
));
window.push_notification(t!("common.copied"), cx);
})),
)
})
.child(
div()
.w_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.rounded_2xl()
.shadow_md()
.when(cx.theme().mode.is_dark(), |this| {
this.shadow_none()
.border_1()
.border_color(cx.theme().border)
})
.bg(cx.theme().background)
.child(img(qr).h_64())
.on_click(cx.listener(move |this, _, window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
this.connection_string.read(cx).to_string(),
));
window.push_notification(
t!("common.copied").to_string(),
cx,
);
})),
)
})
.child(
div()
.w_full()
.flex()
.items_center()
.justify_center()
.gap_1()
.child(TextInput::new(&self.relay_input).xsmall())
.child(
Button::new("change")
.label(t!("common.change"))
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, window, cx| {
this.change_relay(window, cx);
})),
),
),
),
.gap_1()
.child(TextInput::new(&self.relay_input).xsmall())
.child(
Button::new("change")
.label(t!("common.change"))
.ghost()
.xsmall()
.on_click(cx.listener(
move |this, _, window, cx| {
this.change_relay(window, cx);
},
)),
),
),
),
),
)
}
}

View File

@@ -0,0 +1,396 @@
use std::time::Duration;
use anyhow::{anyhow, Error};
use global::constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID, NIP17_RELAYS};
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
TextAlign, UniformList, Window,
};
use i18n::{shared_t, t};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<MessagingRelays> {
cx.new(|cx| MessagingRelays::new(window, cx))
}
pub fn relay_button() -> impl IntoElement {
div().child(
Button::new("dm-relays")
.icon(IconName::Info)
.label(t!("relays.button_label"))
.warning()
.xsmall()
.rounded(ButtonRounded::Full)
.on_click(move |_, window, cx| {
let title = SharedString::new(t!("relays.modal_title"));
let view = cx.new(|cx| MessagingRelays::new(window, cx));
let weak_view = view.downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
modal
.confirm()
.title(title.clone())
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
this.set_relays(window, cx);
})
.ok();
// true to close the modal
false
})
})
}),
)
}
pub struct MessagingRelays {
input: Entity<InputState>,
relays: Vec<RelayUrl>,
error: Option<SharedString>,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
}
impl MessagingRelays {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_new::<Self>(move |this, window, cx| {
if let Some(window) = window {
this.load(window, cx);
}
}));
subscriptions.push(cx.subscribe_in(
&input,
window,
move |this: &mut Self, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add(window, cx);
}
},
));
Self {
input,
subscriptions,
relays: vec![],
error: None,
}
}
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first() {
let relays = event
.tags
.filter(TagKind::Relay)
.filter_map(|tag| RelayUrl::parse(tag.content()?).ok())
.collect::<Vec<_>>();
Ok(relays)
} else {
Err(anyhow!("Not found."))
}
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(relays) = task.await {
this.update(cx, |this, cx| {
this.relays = relays;
cx.notify();
})
.ok();
}
})
.detach();
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.input.read(cx).value().to_string();
if !value.starts_with("ws") {
return;
}
if let Ok(url) = RelayUrl::parse(&value) {
if !self.relays.contains(&url) {
self.relays.push(url);
}
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
cx.notify();
}
}
fn remove(&mut self, ix: usize, _window: &mut Window, cx: &mut Context<Self>) {
self.relays.remove(ix);
cx.notify();
}
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
where
E: Into<SharedString>,
{
self.error = Some(error.into());
cx.notify();
// Clear the error message after a delay
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})
.ok();
})
.ok();
})
.detach();
}
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.relays.is_empty() {
self.set_error(t!("relays.empty"), window, cx);
return;
};
let relays = self.relays.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(
relays
.iter()
.map(|relay| Tag::relay(relay.clone()))
.collect_vec(),
);
// Set messaging relays
client.send_event_builder(builder).await?;
// Connect to messaging relays
for relay in relays.into_iter() {
_ = client.add_relay(&relay).await;
_ = client.connect_relay(&relay).await;
}
let all_msg_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
let new_msg_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
let all_messages = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
let new_messages = Filter::new()
.kind(Kind::GiftWrap)
.pubkey(public_key)
.limit(0);
// Close old subscriptions
client.unsubscribe(&all_msg_id).await;
client.unsubscribe(&new_msg_id).await;
// Subscribe to all messages
client
.subscribe_with_id(all_msg_id, all_messages, None)
.await?;
// Subscribe to new messages
client
.subscribe_with_id(new_msg_id, new_messages, None)
.await?;
Ok(())
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
window.close_modal(cx);
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), window, cx);
})
.ok();
})
.ok();
}
};
})
.detach();
}
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
let relays = self.relays.clone();
let total = relays.len();
uniform_list(
"relays",
total,
cx.processor(move |_, range, _window, cx| {
let mut items = Vec::new();
for ix in range {
let item = relays.get(ix).map(|i: &RelayUrl| i.to_string()).unwrap();
items.push(
div().group("").w_full().h_9().py_0p5().child(
div()
.px_2()
.h_full()
.w_full()
.flex()
.items_center()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.text_xs()
.child(item)
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click(cx.listener(move |this, _, window, cx| {
this.remove(ix, window, cx)
})),
),
),
)
}
items
}),
)
.w_full()
.min_h(px(200.))
}
fn render_empty(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.h_20()
.mb_2()
.justify_center()
.text_sm()
.text_align(TextAlign::Center)
.child(SharedString::new(t!("relays.add_some_relays")))
}
}
impl Render for MessagingRelays {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.text_sm()
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("relays.description")),
)
.child(
v_flex()
.gap_2()
.child(
h_flex()
.gap_1()
.w_full()
.child(TextInput::new(&self.input).small())
.child(
Button::new("add")
.icon(IconName::PlusFill)
.label(t!("common.add"))
.ghost()
.on_click(cx.listener(move |this, _, window, cx| {
this.add(window, cx);
})),
),
)
.child(
h_flex()
.gap_2()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(shared_t!("relays.recommended")),
)
.child(h_flex().gap_1().children({
NIP17_RELAYS.iter().map(|&relay| {
div()
.id(relay)
.group("")
.py_0p5()
.px_1p5()
.text_xs()
.text_center()
.bg(cx.theme().secondary_background)
.hover(|this| this.bg(cx.theme().secondary_hover))
.active(|this| this.bg(cx.theme().secondary_active))
.rounded_full()
.child(relay)
.on_click(cx.listener(move |this, _, window, cx| {
this.input.update(cx, |this, cx| {
this.set_value(relay, window, cx);
});
this.add(window, cx);
}))
})
})),
)
.when_some(self.error.as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
)
.map(|this| {
if !self.relays.is_empty() {
this.child(self.render_list(window, cx))
} else {
this.child(self.render_empty(window, cx))
}
})
}
}

View File

@@ -1,11 +1,12 @@
pub mod backup_keys;
pub mod chat;
pub mod compose;
pub mod edit_profile;
pub mod login;
pub mod messaging_relays;
pub mod new_account;
pub mod onboarding;
pub mod preferences;
pub mod relays;
pub mod screening;
pub mod sidebar;
pub mod startup;

View File

@@ -1,22 +1,24 @@
use anyhow::anyhow;
use common::nip96::nip96_upload;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten,
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
Styled, Window,
div, relative, rems, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity,
EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions,
Render, SharedString, Styled, WeakEntity, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputState, TextInput};
use ui::popup_menu::PopupMenu;
use ui::{ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
NewAccount::new(window, cx)
@@ -25,7 +27,6 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
pub struct NewAccount {
name_input: Entity<InputState>,
avatar_input: Entity<InputState>,
bio_input: Entity<InputState>,
is_uploading: bool,
is_submitting: bool,
// Panel
@@ -46,19 +47,12 @@ impl NewAccount {
.placeholder(SharedString::new(t!("profile.placeholder_name")))
});
let bio_input = cx.new(|cx| {
InputState::new(window, cx)
.multi_line()
.placeholder(SharedString::new(t!("profile.placeholder_bio")))
});
let avatar_input =
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.png"));
Self {
name_input,
avatar_input,
bio_input,
is_uploading: false,
is_submitting: false,
name: "New Account".into(),
@@ -69,140 +63,97 @@ impl NewAccount {
}
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_submitting(true, cx);
self.submitting(true, cx);
let identity = Identity::global(cx);
let avatar = self.avatar_input.read(cx).value().to_string();
let name = self.name_input.read(cx).value().to_string();
let bio = self.bio_input.read(cx).value().to_string();
let mut metadata = Metadata::new().display_name(name).about(bio);
// Build metadata
let mut metadata = Metadata::new().display_name(name.clone()).name(name);
if let Ok(url) = Url::parse(&avatar) {
metadata = metadata.picture(url);
};
let current_view = cx.entity().downgrade();
let pwd_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let weak_input = pwd_input.downgrade();
window.open_modal(cx, move |this, _window, _cx| {
let metadata = metadata.clone();
let weak_input = weak_input.clone();
let view_cancel = current_view.clone();
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.on_cancel(move |_, window, cx| {
view_cancel
.update(cx, |_this, cx| {
window.push_notification(t!("new_account.password_invalid"), cx)
})
.ok();
true
})
.on_ok(move |_, window, cx| {
let metadata = metadata.clone();
let value = weak_input
.read_with(cx, |state, _cx| state.value().to_owned())
.ok();
if let Some(password) = value {
Identity::global(cx).update(cx, |this, cx| {
this.new_identity(password.to_string(), metadata, window, cx);
});
}
true
})
.child(
div()
.w_full()
.flex()
.flex_col()
.gap_1()
.text_sm()
.child(SharedString::new(t!("new_account.set_password_prompt")))
.child(TextInput::new(&pwd_input).small()),
)
identity.update(cx, |this, cx| {
this.new_identity(metadata, window, cx);
});
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nip96 = AppSettings::get_media_server(cx);
let avatar_input = self.avatar_input.downgrade();
self.uploading(true, cx);
// Get the user's configured NIP96 server
let nip96_server = AppSettings::get_media_server(cx);
// Open native file dialog
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
self.set_uploading(true, cx);
cx.spawn_in(window, async move |this, cx| {
let task = Tokio::spawn(cx, async move {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
let Some(path) = paths.pop() else {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
.ok();
})
.ok();
if let Some(path) = paths.pop() {
let file = fs::read(path).await?;
let url = nip96_upload(nostr_client(), &nip96_server, file).await?;
return;
};
if let Ok(file_data) = fs::read(path).await {
let (tx, rx) = oneshot::channel::<Url>();
nostr_sdk::async_utility::task::spawn(async move {
if let Ok(url) = nip96_upload(nostr_client(), &nip96, file_data).await {
_ = tx.send(url);
}
});
if let Ok(url) = rx.await {
cx.update(|window, cx| {
// Stop loading spinner
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
.ok();
// Set avatar input
avatar_input
.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
})
.ok();
})
.ok();
}
Ok(url)
} else {
Err(anyhow!("Path not found"))
}
}
Ok(None) => {
cx.update(|_, cx| {
Ok(None) => Err(anyhow!("User cancelled")),
Err(e) => Err(anyhow!("File dialog error: {e}")),
}
});
cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(task.await.map_err(|e| e.into())) {
Ok(Ok(url)) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
this.uploading(false, cx);
this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
});
})
.ok();
})
.ok();
}
Err(_) => {}
Ok(Err(e)) => {
Self::notify_error(cx, this, e.to_string());
}
Err(e) => {
Self::notify_error(cx, this, e.to_string());
}
}
})
.detach();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
fn notify_error(cx: &mut AsyncWindowContext, entity: WeakEntity<NewAccount>, e: String) {
cx.update(|window, cx| {
entity
.update(cx, |this, cx| {
window.push_notification(e, cx);
this.uploading(false, cx);
})
.ok();
})
.ok();
}
fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
cx.notify();
}
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_uploading = status;
cx.notify();
}
@@ -243,93 +194,72 @@ impl Focusable for NewAccount {
}
impl Render for NewAccount {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.relative()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_10()
.child(
div()
.text_center()
.text_lg()
.text_center()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::new(t!("new_account.title"))),
)
.child(
div()
.w_72()
.flex()
.flex_col()
.gap_3()
v_flex()
.w_96()
.gap_4()
.child(
div()
.w_full()
.h_32()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.map(|this| {
if self.avatar_input.read(cx).value().is_empty() {
this.child(
img("brand/avatar.png")
.rounded_full()
.size_10()
.flex_shrink_0(),
)
} else {
this.child(
img(self.avatar_input.read(cx).value().clone())
.rounded_full()
.size_10()
.flex_shrink_0(),
)
}
})
.child(
Button::new("upload")
.label(t!("profile.set_profile_picture"))
.icon(Icon::new(IconName::Plus))
.ghost()
.small()
.disabled(self.is_submitting)
.loading(self.is_uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
)
.child(
div()
.flex()
.flex_col()
v_flex()
.gap_1()
.text_sm()
.child(SharedString::new(t!("profile.label_name")))
.child(SharedString::new(t!("new_account.name")))
.child(TextInput::new(&self.name_input).small()),
)
.child(
div()
.flex()
.flex_col()
v_flex()
.gap_1()
.text_sm()
.child(SharedString::new(t!("profile.label_bio")))
.child(TextInput::new(&self.bio_input).small()),
)
.child(
div()
.my_2()
.w_full()
.h_px()
.bg(cx.theme().elevated_surface_background),
.child(
div()
.text_sm()
.child(SharedString::new(t!("new_account.avatar"))),
)
.child(
v_flex()
.p_1()
.h_32()
.w_full()
.items_center()
.justify_center()
.gap_2()
.rounded(cx.theme().radius)
.border_1()
.border_dashed()
.border_color(cx.theme().border)
.child(
Avatar::new(self.avatar_input.read(cx).value().to_string())
.size(rems(2.25)),
)
.child(
Button::new("upload")
.icon(IconName::Plus)
.label(t!("common.upload"))
.ghost()
.small()
.rounded(ButtonRounded::Full)
.disabled(self.is_submitting)
.loading(self.is_uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
),
)
.child(divider(cx))
.child(
Button::new("submit")
.label(SharedString::new(t!("common.continue")))

View File

@@ -15,7 +15,7 @@ use nostr_sdk::prelude::*;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::checkbox::Checkbox;
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
@@ -244,12 +244,13 @@ impl Render for Onboarding {
}),
)
.child(
div().w_24().absolute().bottom_4().right_4().child(
Button::new("unload")
div().w_24().absolute().bottom_2().right_2().child(
Button::new("logout")
.icon(IconName::Logout)
.label(SharedString::new(t!("common.logout")))
.ghost()
.small()
.label(SharedString::new(t!("user.sign_out")))
.danger()
.xsmall()
.rounded(ButtonRounded::Full)
.disabled(self.loading)
.on_click(|_, window, cx| {
Identity::global(cx).update(cx, |this, cx| {

View File

@@ -1,5 +1,4 @@
use common::display::DisplayProfile;
use global::constants::{DEFAULT_MODAL_WIDTH, NIP96_SERVER};
use gpui::http_client::Url;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -14,10 +13,11 @@ use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::switch::Switch;
use ui::{v_flex, ContextModal, IconName, Sizable, Size, StyledExt};
use crate::views::{edit_profile, relays};
use crate::views::{edit_profile, messaging_relays};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
Preferences::new(window, cx)
@@ -33,8 +33,8 @@ impl Preferences {
let media_server = AppSettings::get_media_server(cx).to_string();
let media_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(media_server)
.placeholder(NIP96_SERVER)
.default_value(media_server.clone())
.placeholder(media_server)
});
Self { media_input }
@@ -42,25 +42,73 @@ impl Preferences {
}
fn open_edit_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
let edit_profile = edit_profile::init(window, cx);
let view = edit_profile::init(window, cx);
let weak_view = view.downgrade();
let title = SharedString::new(t!("profile.title"));
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
modal
.confirm()
.title(title.clone())
.width(px(DEFAULT_MODAL_WIDTH))
.child(edit_profile.clone())
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
let set_metadata = this.set_metadata(cx);
cx.spawn_in(window, async move |_, cx| {
match set_metadata.await {
Ok(event) => {
if let Some(event) = event {
cx.update(|_, cx| {
Registry::global(cx).update(cx, |this, cx| {
this.insert_or_update_person(event, cx);
});
})
.ok();
}
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
};
})
.detach();
})
.ok();
// true to close the modal
true
})
});
}
fn open_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let relays = relays::init(window, cx);
let title = SharedString::new(t!("preferences.modal_relays_title"));
let title = SharedString::new(t!("relays.modal_title"));
let view = messaging_relays::init(window, cx);
let weak_view = view.downgrade();
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(DEFAULT_MODAL_WIDTH))
let weak_view = weak_view.clone();
this.confirm()
.title(title.clone())
.child(relays.clone())
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
this.set_relays(window, cx);
})
.ok();
// true to close the modal
false
})
});
}
}
@@ -80,10 +128,8 @@ impl Render for Preferences {
v_flex()
.child(
div()
v_flex()
.py_2()
.flex()
.flex_col()
.gap_2()
.child(
div()
@@ -135,7 +181,7 @@ impl Render for Preferences {
)
.child(
Button::new("relays")
.label("DM Relays")
.label("Messaging Relays")
.ghost()
.small()
.on_click(cx.listener(move |this, _e, window, cx| {
@@ -146,11 +192,8 @@ impl Render for Preferences {
}),
)
.child(
div()
v_flex()
.py_2()
.flex()
.flex_col()
.gap_2()
.border_t_1()
.border_color(cx.theme().border)
.child(
@@ -162,6 +205,7 @@ impl Render for Preferences {
)
.child(
div()
.my_1()
.flex()
.items_start()
.gap_1()
@@ -193,10 +237,8 @@ impl Render for Preferences {
),
)
.child(
div()
v_flex()
.py_2()
.flex()
.flex_col()
.gap_2()
.border_t_1()
.border_color(cx.theme().border)
@@ -208,9 +250,7 @@ impl Render for Preferences {
.child(SharedString::new(t!("preferences.messages_header"))),
)
.child(
div()
.flex()
.flex_col()
v_flex()
.gap_2()
.child(
Switch::new("screening")
@@ -242,10 +282,8 @@ impl Render for Preferences {
),
)
.child(
div()
v_flex()
.py_2()
.flex()
.flex_col()
.gap_2()
.border_t_1()
.border_color(cx.theme().border)
@@ -257,9 +295,7 @@ impl Render for Preferences {
.child(SharedString::new(t!("preferences.display_header"))),
)
.child(
div()
.flex()
.flex_col()
v_flex()
.gap_2()
.child(
Switch::new("hide_user_avatars")

View File

@@ -1,347 +0,0 @@
use anyhow::Error;
use global::constants::NEW_MESSAGE_SUB_ID;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, uniform_list, App, AppContext, Context, Entity, FocusHandle, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, TextAlign,
UniformList, Window,
};
use i18n::t;
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::{ContextModal, Disableable, IconName, Sizable};
const MIN_HEIGHT: f32 = 200.0;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Relays> {
Relays::new(window, cx)
}
pub struct Relays {
relays: Entity<Vec<RelayUrl>>,
input: Entity<InputState>,
focus_handle: FocusHandle,
is_loading: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl Relays {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
let relays = cx.new(|cx| {
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
let relays = event
.tags
.filter(TagKind::Relay)
.filter_map(|tag| RelayUrl::parse(tag.content()?).ok())
.collect::<Vec<_>>();
Ok(relays)
} else {
let relays = vec![
RelayUrl::parse("wss://auth.nostr1.com")?,
RelayUrl::parse("wss://relay.0xchat.com")?,
];
Ok(relays)
}
});
cx.spawn(async move |this, cx| {
if let Ok(relays) = task.await {
cx.update(|cx| {
this.update(cx, |this: &mut Vec<RelayUrl>, cx| {
*this = relays;
cx.notify();
})
.ok();
})
.ok();
}
})
.detach();
vec![]
});
cx.new(|cx| {
let mut subscriptions = smallvec![];
subscriptions.push(cx.subscribe_in(
&input,
window,
move |this: &mut Relays, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add(window, cx);
}
},
));
Self {
relays,
input,
subscriptions,
is_loading: false,
focus_handle: cx.focus_handle(),
}
})
}
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_loading(true, cx);
let relays = self.relays.read(cx).clone();
let task: Task<Result<EventId, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
// If user didn't have any NIP-65 relays, add default ones
if client.database().relay_list(public_key).await?.is_empty() {
let builder = EventBuilder::relay_list(vec![
(RelayUrl::parse("wss://relay.damus.io/").unwrap(), None),
(RelayUrl::parse("wss://relay.primal.net/").unwrap(), None),
]);
if let Err(e) = client.send_event_builder(builder).await {
log::error!("Failed to send relay list event: {e}");
}
}
let tags: Vec<Tag> = relays
.iter()
.map(|relay| Tag::custom(TagKind::Relay, vec![relay.to_string()]))
.collect();
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
let output = client.send_event_builder(builder).await?;
// Connect to messaging relays
for relay in relays.into_iter() {
_ = client.add_relay(&relay).await;
_ = client.connect_relay(&relay).await;
}
let sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
// Close old subscription
client.unsubscribe(&sub_id).await;
// Subscribe to new messages
if let Err(e) = client
.subscribe_with_id(
sub_id,
Filter::new()
.kind(Kind::GiftWrap)
.pubkey(public_key)
.limit(0),
None,
)
.await
{
log::error!("Failed to subscribe to new messages: {e}");
}
Ok(output.val)
});
cx.spawn_in(window, async move |this, cx| {
if task.await.is_ok() {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_loading(false, cx);
cx.notify();
})
.ok();
window.close_modal(cx);
})
.ok();
}
})
.detach();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.input.read(cx).value().to_string();
if !value.starts_with("ws") {
return;
}
if let Ok(url) = RelayUrl::parse(&value) {
self.relays.update(cx, |this, cx| {
if !this.contains(&url) {
this.push(url);
cx.notify();
}
});
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
}
}
fn remove(&mut self, ix: usize, _window: &mut Window, cx: &mut Context<Self>) {
self.relays.update(cx, |this, cx| {
this.remove(ix);
cx.notify();
});
}
fn render_list(
&mut self,
relays: Vec<RelayUrl>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> UniformList {
let total = relays.len();
uniform_list(
"relays",
total,
cx.processor(move |_, range, _window, cx| {
let mut items = Vec::new();
for ix in range {
let item = relays.get(ix).map(|i: &RelayUrl| i.to_string()).unwrap();
items.push(
div().group("").w_full().h_9().py_0p5().child(
div()
.px_2()
.h_full()
.w_full()
.flex()
.items_center()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.text_xs()
.child(item)
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click(cx.listener(move |this, _, window, cx| {
this.remove(ix, window, cx)
})),
),
),
)
}
items
}),
)
.w_full()
.min_h(px(MIN_HEIGHT))
}
fn render_empty(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.h_20()
.mb_2()
.flex()
.items_center()
.justify_center()
.text_sm()
.text_align(TextAlign::Center)
.child(SharedString::new(t!("relays.add_some_relays")))
}
}
impl Render for Relays {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.track_focus(&self.focus_handle)
.size_full()
.px_3()
.pb_3()
.flex()
.flex_col()
.justify_between()
.child(
div()
.flex_1()
.flex()
.flex_col()
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("relays.description"))),
)
.child(
div()
.w_full()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.items_center()
.w_full()
.gap_2()
.child(TextInput::new(&self.input).small())
.child(
Button::new("add_relay_btn")
.icon(IconName::Plus)
.label(t!("common.add"))
.small()
.ghost()
.rounded_md()
.on_click(cx.listener(|this, _, window, cx| {
this.add(window, cx)
})),
),
)
.map(|this| {
let relays = self.relays.read(cx).clone();
if !relays.is_empty() {
this.child(self.render_list(relays, window, cx))
} else {
this.child(self.render_empty(window, cx))
}
}),
),
)
.child(
Button::new("submti")
.label(t!("common.update"))
.primary()
.w_full()
.loading(self.is_loading)
.disabled(self.is_loading)
.on_click(cx.listener(|this, _, window, cx| {
this.update(window, cx);
})),
)
}
}

View File

@@ -4,15 +4,14 @@ use std::time::Duration;
use anyhow::{anyhow, Error};
use common::debounced_delay::DebouncedDelay;
use common::display::{DisplayProfile, TextUtils};
use global::constants::{BOOTSTRAP_RELAYS, DEFAULT_MODAL_WIDTH, SEARCH_RELAYS};
use common::display::TextUtils;
use global::constants::{BOOTSTRAP_RELAYS, SEARCH_RELAYS};
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, relative, rems, uniform_list, AnyElement, App, AppContext, ClipboardItem, Context,
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription,
Task, Window,
div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString,
Styled, Subscription, Task, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
@@ -25,7 +24,6 @@ use registry::{Registry, RoomEmitter};
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
@@ -34,8 +32,6 @@ use ui::popup_menu::PopupMenu;
use ui::skeleton::Skeleton;
use ui::{v_flex, ContextModal, IconName, Selectable, Sizable, StyledExt};
use crate::views::compose;
mod list_item;
const FIND_DELAY: u64 = 600;
@@ -551,18 +547,6 @@ impl Sidebar {
});
}
fn open_compose(&self, window: &mut Window, cx: &mut Context<Self>) {
let compose = compose::init(window, cx);
let title = SharedString::new(t!("sidebar.direct_messages"));
window.open_modal(cx, move |modal, _window, _cx| {
modal
.title(title.clone())
.width(px(DEFAULT_MODAL_WIDTH))
.child(compose.clone())
});
}
fn open_loading_modal(&self, window: &mut Window, cx: &mut Context<Self>) {
let title = SharedString::new(t!("sidebar.loading_modal_title"));
let text_1 = SharedString::new(t!("sidebar.loading_modal_body_1"));
@@ -596,49 +580,6 @@ impl Sidebar {
});
}
fn account(&self, profile: &Profile, cx: &Context<Self>) -> impl IntoElement {
let proxy = AppSettings::get_proxy_user_avatars(cx);
div()
.px_3()
.h_8()
.flex_none()
.flex()
.justify_between()
.items_center()
.child(
div()
.id("current-user")
.flex()
.items_center()
.gap_2()
.text_sm()
.font_semibold()
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(1.75)))
.child(profile.display_name())
.on_click(cx.listener({
let Ok(public_key) = profile.public_key().to_bech32();
let item = ClipboardItem::new_string(public_key);
move |_, _, window, cx| {
cx.write_to_clipboard(item.clone());
window.push_notification(t!("common.copied"), cx);
}
})),
)
.child(
Button::new("compose")
.icon(IconName::PlusFill)
.tooltip(t!("sidebar.dm_tooltip"))
.small()
.primary()
.rounded(ButtonRounded::Full)
.on_click(cx.listener(|this, _, window, cx| {
this.open_compose(window, cx);
})),
)
}
fn skeletons(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
(0..total).map(|_| {
div()
@@ -727,9 +668,6 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let registry = Registry::read_global(cx);
let profile = Identity::read_global(cx)
.public_key()
.map(|pk| registry.get_person(&pk, cx));
// Get rooms from either search results or the chat registry
let rooms = if let Some(results) = self.local_result.read(cx).as_ref() {
@@ -745,22 +683,17 @@ impl Render for Sidebar {
}
};
div()
v_flex()
.image_cache(self.image_cache.clone())
.size_full()
.relative()
.flex()
.flex_col()
.gap_3()
// Account
.when_some(profile, |this, profile| {
this.child(self.account(&profile, cx))
})
// Search Input
.child(
div()
.relative()
.px_3()
.mt_3()
.px_2p5()
.w_full()
.h_7()
.flex_none()
@@ -781,14 +714,12 @@ impl Render for Sidebar {
)
// Chat Rooms
.child(
div()
.px_2()
.w_full()
.flex_1()
.overflow_y_hidden()
.flex()
.flex_col()
v_flex()
.gap_1()
.flex_1()
.px_1p5()
.w_full()
.overflow_y_hidden()
.child(
div()
.flex_none()
@@ -879,8 +810,11 @@ impl Render for Sidebar {
),
)
.when(registry.loading, |this| {
let title = SharedString::new(t!("sidebar.retrieving_messages"));
let desc = SharedString::new(t!("sidebar.retrieving_messages_description"));
this.child(
div().absolute().bottom_4().px_4().w_full().child(
div().absolute().bottom_3().px_3().w_full().child(
div()
.p_1()
.w_full()
@@ -890,35 +824,28 @@ impl Render for Sidebar {
.justify_between()
.bg(cx.theme().panel_background)
.shadow_sm()
// Empty div
.child(div().size_6().flex_shrink_0())
// Loading indicator
// Loading
.child(div().flex_shrink_0().pl_1().child(Indicator::new().small()))
// Title
.child(
div()
v_flex()
.flex_1()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_xs()
.text_center()
.child(
div()
.text_sm()
.font_semibold()
.flex()
.items_center()
.gap_1()
.line_height(relative(1.2))
.child(Indicator::new().xsmall())
.child(SharedString::new(t!(
"sidebar.retrieving_messages"
))),
.child(title.clone()),
)
.child(div().text_color(cx.theme().text_muted).child(
SharedString::new(t!(
"sidebar.retrieving_messages_description"
)),
)),
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(desc.clone()),
),
)
// Info button
.child(

View File

@@ -40,7 +40,7 @@ impl UserProfile {
})
}
pub fn on_load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Skip if user isn't logged in
let Some(identity) = Identity::read_global(cx).public_key() else {
return;
@@ -100,11 +100,6 @@ impl UserProfile {
self.profile(cx).metadata().nip05
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut App) {
let Ok(bech32) = self.public_key.to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}"));
}
fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.public_key.to_bech32();
let item = ClipboardItem::new_string(bech32);
@@ -221,7 +216,7 @@ impl Render for UserProfile {
.child(shared_bech32),
)
.child(
Button::new("copy-pubkey")
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircleFill
@@ -259,14 +254,5 @@ impl Render for UserProfile {
),
),
)
.child(
Button::new("open-njump")
.label(t!("profile.njump"))
.primary()
.small()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_njump(window, cx);
})),
)
}
}

View File

@@ -27,7 +27,7 @@ pub const NIP65_RELAYS: [&str; 4] = [
];
/// Messaging Relays. Used for new account
pub const NIP17_RELAYS: [&str; 2] = ["wss://auth.nostr1.com", "wss://relay.0xchat.com"];
pub const NIP17_RELAYS: [&str; 2] = ["wss://nip17.com", "wss://relay.0xchat.com"];
/// Default relay for Nostr Connect
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";

View File

@@ -31,7 +31,10 @@ impl Global for GlobalIdentity {}
pub struct Identity {
public_key: Option<PublicKey>,
auto_logging_in_progress: bool,
logging_in: bool,
relay_ready: Option<bool>,
need_backup: Option<Keys>,
need_onboarding: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
@@ -66,14 +69,17 @@ impl Identity {
this.set_logging_in(true, cx);
this.load(window, cx);
} else {
this.set_public_key(None, cx);
this.set_public_key(None, window, cx);
}
}),
);
Self {
public_key: None,
auto_logging_in_progress: false,
relay_ready: None,
need_backup: None,
need_onboarding: false,
logging_in: false,
subscriptions,
}
}
@@ -105,8 +111,11 @@ impl Identity {
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_public_key(None, cx);
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_public_key(None, window, cx);
})
.ok();
})
.ok();
}
@@ -129,19 +138,24 @@ impl Identity {
Ok(())
});
cx.spawn_in(window, async move |this, cx| match task.await {
Ok(_) => {
this.update(cx, |this, cx| {
this.set_public_key(None, cx);
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_public_key(None, window, cx);
})
.ok();
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
};
})
.detach();
}
@@ -158,13 +172,13 @@ impl Identity {
self.login_with_bunker(uri, window, cx);
} else {
window.push_notification(Notification::error("Bunker URI is invalid"), cx);
self.set_public_key(None, cx);
self.set_public_key(None, window, cx);
}
} else if let Ok(enc) = EncryptedSecretKey::from_bech32(secret) {
self.login_with_keys(enc, window, cx);
} else {
window.push_notification(Notification::error("Secret Key is invalid"), cx);
self.set_public_key(None, cx);
self.set_public_key(None, window, cx);
}
}
@@ -182,7 +196,7 @@ impl Identity {
Notification::error("Bunker URI is invalid").title("Nostr Connect"),
cx,
);
self.set_public_key(None, cx);
self.set_public_key(None, window, cx);
return;
};
// Automatically open auth url
@@ -204,7 +218,7 @@ impl Identity {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
this.update(cx, |this, cx| {
this.set_public_key(None, cx);
this.set_public_key(None, window, cx);
})
.ok();
})
@@ -239,10 +253,10 @@ impl Identity {
.show_close(false)
.keyboard(false)
.confirm()
.on_cancel(move |_, _window, cx| {
.on_cancel(move |_, window, cx| {
entity
.update(cx, |this, cx| {
this.set_public_key(None, cx);
this.set_public_key(None, window, cx);
})
.ok();
// Close modal
@@ -341,8 +355,10 @@ impl Identity {
let task: Task<Result<PublicKey, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let public_key = signer.get_public_key().await?;
// Update signer
client.set_signer(signer).await;
// Subscribe for user metadata
Self::subscribe(client, public_key).await?;
@@ -352,8 +368,11 @@ impl Identity {
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(public_key) => {
this.update(cx, |this, cx| {
this.set_public_key(Some(public_key), cx);
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_public_key(Some(public_key), window, cx);
})
.ok();
})
.ok();
}
@@ -368,10 +387,9 @@ impl Identity {
.detach();
}
/// Creates a new identity with the given keys and metadata
/// Creates a new identity with the given metadata
pub fn new_identity(
&mut self,
password: String,
metadata: Metadata,
window: &mut Window,
cx: &mut Context<Self>,
@@ -382,34 +400,32 @@ impl Identity {
let task: Task<Result<PublicKey, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let public_key = async_keys.public_key();
// Update signer
client.set_signer(async_keys).await;
// Set metadata
client.set_metadata(&metadata).await?;
// Create relay list
let relay_list = EventBuilder::new(Kind::RelayList, "").tags(
NIP65_RELAYS.into_iter().filter_map(|url| {
if let Ok(url) = RelayUrl::parse(url) {
Some(Tag::relay_metadata(url, None))
} else {
None
}
}),
NIP65_RELAYS
.into_iter()
.filter_map(|url| RelayUrl::parse(url).ok())
.map(|url| Tag::relay_metadata(url, None)),
);
// Create messaging relay list
let dm_relay = EventBuilder::new(Kind::InboxRelays, "").tags(
NIP17_RELAYS.into_iter().filter_map(|url| {
if let Ok(url) = RelayUrl::parse(url) {
Some(Tag::relay(url))
} else {
None
}
}),
NIP17_RELAYS
.into_iter()
.filter_map(|url| RelayUrl::parse(url).ok())
.map(Tag::relay),
);
// Set user's NIP65 relays
client.send_event_builder(relay_list).await?;
// Set user's NIP17 relays
client.send_event_builder(dm_relay).await?;
// Subscribe for user metadata
@@ -421,9 +437,13 @@ impl Identity {
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(public_key) => {
this.update(cx, |this, cx| {
this.write_keys(&keys, password, cx);
this.set_public_key(Some(public_key), cx);
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_public_key(Some(public_key), window, cx);
this.set_need_backup(Some(keys), cx);
this.set_need_onboarding(cx);
})
.ok();
})
.ok();
}
@@ -438,6 +458,40 @@ impl Identity {
.detach();
}
/// Clear the user's need backup status
pub fn clear_need_backup(&mut self, password: String, cx: &mut Context<Self>) {
if let Some(keys) = self.need_backup.as_ref() {
// Encrypt the keys then writing them to keychain
self.write_keys(keys, password, cx);
// Clear the needed backup keys
self.need_backup = None;
cx.notify();
}
}
/// Set the user's need backup status
pub(crate) fn set_need_backup(&mut self, keys: Option<Keys>, cx: &mut Context<Self>) {
self.need_backup = keys;
cx.notify();
}
/// Set the user's need onboarding status
pub(crate) fn set_need_onboarding(&mut self, cx: &mut Context<Self>) {
self.need_onboarding = true;
cx.notify();
}
/// Returns true if the user needs backup their keys
pub fn need_backup(&self) -> Option<&Keys> {
self.need_backup.as_ref()
}
/// Returns true if the user needs onboarding
pub fn need_onboarding(&self) -> bool {
self.need_onboarding
}
/// Writes the bunker uri to the database
pub fn write_bunker(&self, uri: &NostrConnectURI, cx: &mut Context<Self>) {
let mut value = uri.to_string();
@@ -469,6 +523,7 @@ impl Identity {
.detach();
}
/// Writes the keys to the database
pub fn write_keys(&self, keys: &Keys, password: String, cx: &mut Context<Self>) {
let keys = keys.to_owned();
let public_key = keys.public_key();
@@ -495,9 +550,61 @@ impl Identity {
.detach();
}
pub(crate) fn set_public_key(&mut self, public_key: Option<PublicKey>, cx: &mut Context<Self>) {
fn verify_dm_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let Some(public_key) = self.public_key() else {
return;
};
let task: Task<bool> = cx.background_spawn(async move {
let client = nostr_client();
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
let Ok(events) = client.database().query(filter).await else {
return false;
};
let Some(event) = events.first() else {
return false;
};
let relays: Vec<RelayUrl> = event
.tags
.filter(TagKind::Relay)
.filter_map(|tag| RelayUrl::parse(tag.content()?).ok())
.collect();
!relays.is_empty()
});
cx.spawn_in(window, async move |this, cx| {
let result = task.await;
log::info!("result: {result}");
this.update(cx, |this, cx| {
this.relay_ready = Some(result);
cx.notify();
})
.ok();
})
.detach();
}
/// Sets the public key of the identity
pub(crate) fn set_public_key(
&mut self,
public_key: Option<PublicKey>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.public_key = public_key;
cx.notify();
// Run verify user's dm relays task
cx.defer_in(window, |this, window, cx| {
this.verify_dm_relays(window, cx);
});
}
/// Returns the current identity's public key
@@ -510,12 +617,18 @@ impl Identity {
self.public_key.is_some()
}
pub fn logging_in(&self) -> bool {
self.auto_logging_in_progress
pub fn relay_ready(&self) -> Option<bool> {
self.relay_ready
}
/// Returns true if the identity is currently logging in
pub fn logging_in(&self) -> bool {
self.logging_in
}
/// Sets the logging in status of the identity
pub(crate) fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.auto_logging_in_progress = status;
self.logging_in = status;
cx.notify();
}

View File

@@ -4,10 +4,20 @@ use colors::{brand, hsl, neutral};
use gpui::{px, App, Global, Hsla, Pixels, SharedString, Window, WindowAppearance};
use crate::colors::{danger, warning};
use crate::platform_kind::PlatformKind;
use crate::scrollbar_mode::ScrollBarMode;
mod colors;
mod scale;
pub mod platform_kind;
pub mod scrollbar_mode;
/// Defines window border radius for platforms that use client side decorations.
pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0);
/// Defines window shadow size for platforms that use client side decorations.
pub const CLIENT_SIDE_DECORATION_SHADOW: Pixels = px(10.0);
pub fn init(cx: &mut App) {
Theme::sync_system_appearance(None, cx);
}
@@ -21,7 +31,7 @@ pub struct ThemeColor {
pub panel_background: Hsla,
pub overlay: Hsla,
pub title_bar: Hsla,
pub title_bar_border: Hsla,
pub title_bar_inactive: Hsla,
pub window_border: Hsla,
// Border colors
@@ -78,6 +88,7 @@ pub struct ThemeColor {
// Ghost element colors
pub ghost_element_background: Hsla,
pub ghost_element_background_alt: Hsla,
pub ghost_element_hover: Hsla,
pub ghost_element_active: Hsla,
pub ghost_element_selected: Hsla,
@@ -116,7 +127,7 @@ impl ThemeColor {
panel_background: gpui::white(),
overlay: neutral().light_alpha().step_3(),
title_bar: gpui::transparent_black(),
title_bar_border: gpui::transparent_black(),
title_bar_inactive: neutral().light().step_1(),
window_border: hsl(240.0, 5.9, 78.0),
border: neutral().light().step_6(),
@@ -158,16 +169,17 @@ impl ThemeColor {
danger_disabled: danger().light_alpha().step_3(),
warning_foreground: warning().light().step_12(),
warning_background: warning().light().step_9(),
warning_hover: warning().light_alpha().step_10(),
warning_active: warning().light().step_10(),
warning_selected: warning().light().step_11(),
warning_background: warning().light().step_3(),
warning_hover: warning().light_alpha().step_4(),
warning_active: warning().light().step_5(),
warning_selected: warning().light().step_5(),
warning_disabled: warning().light_alpha().step_3(),
ghost_element_background: gpui::transparent_black(),
ghost_element_hover: neutral().light_alpha().step_3(),
ghost_element_active: neutral().light_alpha().step_5(),
ghost_element_selected: neutral().light_alpha().step_5(),
ghost_element_background_alt: neutral().light().step_3(),
ghost_element_hover: neutral().light_alpha().step_4(),
ghost_element_active: neutral().light().step_5(),
ghost_element_selected: neutral().light().step_5(),
ghost_element_disabled: neutral().light_alpha().step_2(),
tab_inactive_background: neutral().light().step_3(),
@@ -197,7 +209,7 @@ impl ThemeColor {
panel_background: gpui::black(),
overlay: neutral().dark_alpha().step_3(),
title_bar: gpui::transparent_black(),
title_bar_border: gpui::transparent_black(),
title_bar_inactive: neutral().dark().step_1(),
window_border: hsl(240.0, 3.7, 28.0),
border: neutral().dark().step_6(),
@@ -239,16 +251,17 @@ impl ThemeColor {
danger_disabled: danger().dark_alpha().step_3(),
warning_foreground: warning().dark().step_12(),
warning_background: warning().dark().step_9(),
warning_hover: warning().dark_alpha().step_10(),
warning_active: warning().dark().step_10(),
warning_selected: warning().dark().step_11(),
warning_background: warning().dark().step_3(),
warning_hover: warning().dark_alpha().step_4(),
warning_active: warning().dark().step_5(),
warning_selected: warning().dark().step_5(),
warning_disabled: warning().dark_alpha().step_3(),
ghost_element_background: gpui::transparent_black(),
ghost_element_hover: neutral().dark_alpha().step_3(),
ghost_element_active: neutral().dark_alpha().step_4(),
ghost_element_selected: neutral().dark_alpha().step_5(),
ghost_element_background_alt: neutral().dark().step_3(),
ghost_element_hover: neutral().dark_alpha().step_4(),
ghost_element_active: neutral().dark().step_5(),
ghost_element_selected: neutral().dark().step_5(),
ghost_element_disabled: neutral().dark_alpha().step_2(),
tab_inactive_background: neutral().dark().step_3(),
@@ -309,24 +322,6 @@ impl From<WindowAppearance> for ThemeMode {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum ScrollBarMode {
#[default]
Scrolling,
Hover,
Always,
}
impl ScrollBarMode {
pub fn is_hover(&self) -> bool {
matches!(self, Self::Hover)
}
pub fn is_always(&self) -> bool {
matches!(self, Self::Always)
}
}
#[derive(Debug, Clone)]
pub struct Theme {
pub colors: ThemeColor,
@@ -335,6 +330,7 @@ pub struct Theme {
pub font_size: Pixels,
pub radius: Pixels,
pub scrollbar_mode: ScrollBarMode,
pub platform_kind: PlatformKind,
}
impl Deref for Theme {
@@ -412,6 +408,7 @@ impl From<ThemeColor> for Theme {
font_family: ".SystemUIFont".into(),
radius: px(5.),
scrollbar_mode: ScrollBarMode::default(),
platform_kind: PlatformKind::platform(),
mode,
colors,
}

View File

@@ -0,0 +1,30 @@
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub enum PlatformKind {
Mac,
Linux,
Windows,
}
impl PlatformKind {
pub const fn platform() -> Self {
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
Self::Linux
} else if cfg!(target_os = "windows") {
Self::Windows
} else {
Self::Mac
}
}
pub fn is_linux(&self) -> bool {
matches!(self, Self::Linux)
}
pub fn is_windows(&self) -> bool {
matches!(self, Self::Windows)
}
pub fn is_mac(&self) -> bool {
matches!(self, Self::Mac)
}
}

View File

@@ -0,0 +1,21 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum ScrollBarMode {
#[default]
Scrolling,
Hover,
Always,
}
impl ScrollBarMode {
pub fn is_scrolling(&self) -> bool {
matches!(self, Self::Scrolling)
}
pub fn is_hover(&self) -> bool {
matches!(self, Self::Hover)
}
pub fn is_always(&self) -> bool {
matches!(self, Self::Always)
}
}

View File

@@ -0,0 +1,25 @@
[package]
name = "title_bar"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
theme = { path = "../theme" }
ui = { path = "../ui" }
rust-i18n.workspace = true
i18n.workspace = true
nostr-sdk.workspace = true
gpui.workspace = true
smol.workspace = true
smallvec.workspace = true
anyhow.workspace = true
log.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.61", features = ["Wdk_System_SystemServices"] }
[target.'cfg(target_os = "linux")'.dependencies]
linicon = "2.3.0"

178
crates/title_bar/src/lib.rs Normal file
View File

@@ -0,0 +1,178 @@
use std::mem;
use gpui::prelude::FluentBuilder;
#[cfg(target_os = "linux")]
use gpui::MouseButton;
use gpui::{
div, px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement,
ParentElement, Pixels, Render, StatefulInteractiveElement as _, Styled, Window,
WindowControlArea,
};
use smallvec::{smallvec, SmallVec};
use theme::platform_kind::PlatformKind;
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
use ui::h_flex;
#[cfg(target_os = "linux")]
use crate::platforms::linux::LinuxWindowControls;
use crate::platforms::windows::WindowsWindowControls;
mod platforms;
pub struct TitleBar {
children: SmallVec<[AnyElement; 2]>,
should_move: bool,
}
impl Default for TitleBar {
fn default() -> Self {
Self::new()
}
}
impl TitleBar {
pub fn new() -> Self {
Self {
children: smallvec![],
should_move: false,
}
}
#[cfg(not(target_os = "windows"))]
pub fn height(window: &mut Window) -> Pixels {
(1.75 * window.rem_size()).max(px(34.))
}
#[cfg(target_os = "windows")]
pub fn height(_window: &mut Window) -> Pixels {
px(32.)
}
pub fn title_bar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if window.is_window_active() && !self.should_move {
cx.theme().title_bar
} else {
cx.theme().title_bar_inactive
}
} else {
cx.theme().title_bar
}
}
pub fn set_children<T>(&mut self, children: T)
where
T: IntoIterator<Item = AnyElement>,
{
self.children = children.into_iter().collect();
}
}
impl ParentElement for TitleBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl Render for TitleBar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
#[cfg(target_os = "linux")]
let supported_controls = window.window_controls();
let decorations = window.window_decorations();
let height = Self::height(window);
let color = self.title_bar_color(window, cx);
let children = mem::take(&mut self.children);
h_flex()
.window_control_area(WindowControlArea::Drag)
.w_full()
.h(height)
.map(|this| {
if window.is_fullscreen() {
this.pl_2()
} else if cx.theme().platform_kind.is_mac() {
this.pl(px(platforms::mac::TRAFFIC_LIGHT_PADDING))
} else {
this.pl_2()
}
})
.map(|this| match decorations {
Decorations::Server => this,
Decorations::Client { tiling, .. } => this
.when(!(tiling.top || tiling.right), |el| {
el.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |el| {
el.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
}),
})
.bg(color)
.content_stretch()
.child(
div()
.id("title-bar")
.flex()
.flex_row()
.items_center()
.justify_between()
.w_full()
.when(cx.theme().platform_kind.is_mac(), |this| {
this.on_click(|event, window, _| {
if event.up.click_count == 2 {
window.titlebar_double_click();
}
})
})
.when(cx.theme().platform_kind.is_linux(), |this| {
this.on_click(|event, window, _| {
if event.up.click_count == 2 {
window.zoom_window();
}
})
})
.children(children),
)
.when(!window.is_fullscreen(), |this| {
match cx.theme().platform_kind {
PlatformKind::Linux => {
#[cfg(target_os = "linux")]
if matches!(decorations, Decorations::Client { .. }) {
this.child(LinuxWindowControls::new(None))
.when(supported_controls.window_menu, |this| {
this.on_mouse_down(MouseButton::Right, move |ev, window, _| {
window.show_window_menu(ev.position)
})
})
.on_mouse_move(cx.listener(move |this, _ev, window, _| {
if this.should_move {
this.should_move = false;
window.start_window_move();
}
}))
.on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| {
this.should_move = false;
}))
.on_mouse_up(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.should_move = false;
}),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.should_move = true;
}),
)
} else {
this
}
#[cfg(not(target_os = "linux"))]
this
}
PlatformKind::Windows => this.child(WindowsWindowControls::new(height)),
PlatformKind::Mac => this,
}
})
}
}

View File

@@ -0,0 +1,229 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
use gpui::prelude::FluentBuilder;
use gpui::{
img, Action, App, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
StatefulInteractiveElement, Styled, Window,
};
use linicon::{lookup_icon, IconType};
use theme::ActiveTheme;
use ui::{h_flex, Icon, IconName, Sizable};
#[derive(IntoElement)]
pub struct LinuxWindowControls {
close_window_action: Option<Box<dyn Action>>,
}
impl LinuxWindowControls {
pub fn new(close_window_action: Option<Box<dyn Action>>) -> Self {
Self {
close_window_action,
}
}
}
impl RenderOnce for LinuxWindowControls {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
h_flex()
.id("linux-window-controls")
.px_2()
.gap_2()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(WindowControl::new(
LinuxControl::Minimize,
IconName::WindowMinimize,
))
.child({
if window.is_maximized() {
WindowControl::new(LinuxControl::Restore, IconName::WindowRestore)
} else {
WindowControl::new(LinuxControl::Maximize, IconName::WindowMaximize)
}
})
.child(
WindowControl::new(LinuxControl::Close, IconName::WindowClose)
.when_some(self.close_window_action, |this, close_action| {
this.close_action(close_action)
}),
)
}
}
#[derive(IntoElement)]
pub struct WindowControl {
kind: LinuxControl,
fallback: IconName,
close_action: Option<Box<dyn Action>>,
}
impl WindowControl {
pub fn new(kind: LinuxControl, fallback: IconName) -> Self {
Self {
kind,
fallback,
close_action: None,
}
}
pub fn close_action(mut self, action: Box<dyn Action>) -> Self {
self.close_action = Some(action);
self
}
pub fn is_gnome(&self) -> bool {
matches!(detect_desktop_environment(), DesktopEnvironment::Gnome)
}
}
impl RenderOnce for WindowControl {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let is_gnome = self.is_gnome();
h_flex()
.id(self.kind.as_icon_name())
.group("")
.justify_center()
.items_center()
.rounded_full()
.map(|this| {
if is_gnome {
this.size_6()
.bg(cx.theme().tab_inactive_background)
.hover(|this| this.bg(cx.theme().tab_hover_background))
.active(|this| this.bg(cx.theme().tab_active_background))
} else {
this.size_5()
.bg(cx.theme().ghost_element_background)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.active(|this| this.bg(cx.theme().ghost_element_active))
}
})
.map(|this| {
if let Some(Some(path)) = linux_controls().get(&self.kind).cloned() {
this.child(img(path).flex_grow().size_4())
} else {
this.child(Icon::new(self.fallback).flex_grow().small())
}
})
.on_mouse_move(|_, _window, cx| cx.stop_propagation())
.on_click(move |_, window, cx| {
cx.stop_propagation();
match self.kind {
LinuxControl::Minimize => window.minimize_window(),
LinuxControl::Restore => window.zoom_window(),
LinuxControl::Maximize => window.zoom_window(),
LinuxControl::Close => window.dispatch_action(
self.close_action
.as_ref()
.expect("Use WindowControl::new_close() for close control.")
.boxed_clone(),
cx,
),
}
})
}
}
static DE: OnceLock<DesktopEnvironment> = OnceLock::new();
static LINUX_CONTROLS: OnceLock<HashMap<LinuxControl, Option<PathBuf>>> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DesktopEnvironment {
Gnome,
Kde,
Unknown,
}
/// Detect the current desktop environment
pub fn detect_desktop_environment() -> &'static DesktopEnvironment {
DE.get_or_init(|| {
// Try to use environment variables first
if let Ok(output) = std::env::var("XDG_CURRENT_DESKTOP") {
let desktop = output.to_lowercase();
if desktop.contains("gnome") {
return DesktopEnvironment::Gnome;
} else if desktop.contains("kde") {
return DesktopEnvironment::Kde;
}
}
// Fallback detection methods
if let Ok(output) = std::env::var("DESKTOP_SESSION") {
let session = output.to_lowercase();
if session.contains("gnome") {
return DesktopEnvironment::Gnome;
} else if session.contains("kde") || session.contains("plasma") {
return DesktopEnvironment::Kde;
}
}
DesktopEnvironment::Unknown
})
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub enum LinuxControl {
Minimize,
Restore,
Maximize,
Close,
}
impl LinuxControl {
pub fn as_icon_name(&self) -> &'static str {
match self {
LinuxControl::Close => "window-close",
LinuxControl::Minimize => "window-minimize",
LinuxControl::Maximize => "window-maximize",
LinuxControl::Restore => "window-restore",
}
}
}
fn linux_controls() -> &'static HashMap<LinuxControl, Option<PathBuf>> {
LINUX_CONTROLS.get_or_init(|| {
let mut icons = HashMap::new();
icons.insert(LinuxControl::Close, None);
icons.insert(LinuxControl::Minimize, None);
icons.insert(LinuxControl::Maximize, None);
icons.insert(LinuxControl::Restore, None);
let icon_names = [
(LinuxControl::Close, vec!["window-close", "dialog-close"]),
(
LinuxControl::Minimize,
vec!["window-minimize", "window-lower"],
),
(
LinuxControl::Maximize,
vec!["window-maximize", "window-expand"],
),
(
LinuxControl::Restore,
vec!["window-restore", "window-return"],
),
];
for (control, icon_names) in icon_names {
for icon_name in icon_names {
// Try GNOME-style naming first
let mut control_icon = lookup_icon(format!("{icon_name}-symbolic"))
.find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG));
// If not found, try KDE-style naming
if control_icon.is_none() {
control_icon = lookup_icon(icon_name)
.find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG));
}
if let Some(Ok(icon)) = control_icon {
icons.entry(control).and_modify(|v| *v = Some(icon.path));
}
}
}
icons
})
}

View File

@@ -0,0 +1,6 @@
/// Use pixels here instead of a rem-based size because the macOS traffic
/// lights are a static size, and don't scale with the rest of the UI.
///
/// Magic number: There is one extra pixel of padding on the left side due to
/// the 1px border around the window on macOS apps.
pub const TRAFFIC_LIGHT_PADDING: f32 = 71.;

View File

@@ -0,0 +1,4 @@
#[cfg(target_os = "linux")]
pub mod linux;
pub mod mac;
pub mod windows;

View File

@@ -0,0 +1,147 @@
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels,
RenderOnce, Rgba, StatefulInteractiveElement, Styled, Window, WindowControlArea,
};
use theme::ActiveTheme;
use ui::h_flex;
#[derive(IntoElement)]
pub struct WindowsWindowControls {
button_height: Pixels,
}
impl WindowsWindowControls {
pub fn new(button_height: Pixels) -> Self {
Self { button_height }
}
#[cfg(not(target_os = "windows"))]
fn get_font() -> &'static str {
"Segoe Fluent Icons"
}
#[cfg(target_os = "windows")]
fn get_font() -> &'static str {
use windows::Wdk::System::SystemServices::RtlGetVersion;
let mut version = unsafe { std::mem::zeroed() };
let status = unsafe { RtlGetVersion(&mut version) };
if status.is_ok() && version.dwBuildNumber >= 22000 {
"Segoe Fluent Icons"
} else {
"Segoe MDL2 Assets"
}
}
}
impl RenderOnce for WindowsWindowControls {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let close_button_hover_color = Rgba {
r: 232.0 / 255.0,
g: 17.0 / 255.0,
b: 32.0 / 255.0,
a: 1.0,
};
let button_hover_color = cx.theme().ghost_element_hover;
let button_active_color = cx.theme().ghost_element_active;
div()
.id("windows-window-controls")
.font_family(Self::get_font())
.flex()
.flex_row()
.justify_center()
.content_stretch()
.max_h(self.button_height)
.min_h(self.button_height)
.child(WindowsCaptionButton::new(
"minimize",
WindowsCaptionButtonIcon::Minimize,
button_hover_color,
button_active_color,
))
.child(WindowsCaptionButton::new(
"maximize-or-restore",
if window.is_maximized() {
WindowsCaptionButtonIcon::Restore
} else {
WindowsCaptionButtonIcon::Maximize
},
button_hover_color,
button_active_color,
))
.child(WindowsCaptionButton::new(
"close",
WindowsCaptionButtonIcon::Close,
close_button_hover_color,
button_active_color,
))
}
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
enum WindowsCaptionButtonIcon {
Minimize,
Restore,
Maximize,
Close,
}
#[derive(IntoElement)]
struct WindowsCaptionButton {
id: ElementId,
icon: WindowsCaptionButtonIcon,
hover_background_color: Hsla,
active_background_color: Hsla,
}
impl WindowsCaptionButton {
pub fn new(
id: impl Into<ElementId>,
icon: WindowsCaptionButtonIcon,
hover_background_color: impl Into<Hsla>,
active_background_color: impl Into<Hsla>,
) -> Self {
Self {
id: id.into(),
icon,
hover_background_color: hover_background_color.into(),
active_background_color: active_background_color.into(),
}
}
}
impl RenderOnce for WindowsCaptionButton {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
h_flex()
.id(self.id)
.justify_center()
.content_center()
.occlude()
.w(px(36.))
.h_full()
.text_size(px(10.0))
.hover(|style| style.bg(self.hover_background_color))
.active(|style| style.bg(self.active_background_color))
.map(|this| match self.icon {
WindowsCaptionButtonIcon::Close => {
this.window_control_area(WindowControlArea::Close)
}
WindowsCaptionButtonIcon::Maximize | WindowsCaptionButtonIcon::Restore => {
this.window_control_area(WindowControlArea::Max)
}
WindowsCaptionButtonIcon::Minimize => {
this.window_control_area(WindowControlArea::Min)
}
})
.child(match self.icon {
WindowsCaptionButtonIcon::Minimize => "\u{e921}",
WindowsCaptionButtonIcon::Restore => "\u{e923}",
WindowsCaptionButtonIcon::Maximize => "\u{e922}",
WindowsCaptionButtonIcon::Close => "\u{e8bb}",
})
}
}

View File

@@ -8,7 +8,7 @@ use theme::ActiveTheme;
use crate::indicator::Indicator;
use crate::tooltip::Tooltip;
use crate::{Disableable, Icon, Selectable, Sizable, Size, StyledExt};
use crate::{h_flex, Disableable, Icon, Selectable, Sizable, Size, StyledExt};
pub enum ButtonRounded {
Normal,
@@ -48,7 +48,12 @@ pub trait ButtonVariants: Sized {
/// With the ghost style for the Button.
fn ghost(self) -> Self {
self.with_variant(ButtonVariant::Ghost)
self.with_variant(ButtonVariant::Ghost { alt: false })
}
/// With the ghost style for the Button.
fn ghost_alt(self) -> Self {
self.with_variant(ButtonVariant::Ghost { alt: true })
}
/// With the transparent style for the Button.
@@ -100,7 +105,7 @@ pub enum ButtonVariant {
Secondary,
Danger,
Warning,
Ghost,
Ghost { alt: bool },
Transparent,
Custom(ButtonCustomVariant),
}
@@ -118,19 +123,26 @@ type OnClick = Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>
pub struct Button {
pub base: Div,
id: ElementId,
icon: Option<Icon>,
label: Option<SharedString>,
tooltip: Option<SharedString>,
children: Vec<AnyElement>,
disabled: bool,
variant: ButtonVariant,
rounded: ButtonRounded,
size: Size,
disabled: bool,
reverse: bool,
bold: bool,
tooltip: Option<SharedString>,
on_click: OnClick,
cta: bool,
loading: bool,
loading_icon: Option<Icon>,
on_click: OnClick,
pub(crate) selected: bool,
pub(crate) stop_propagation: bool,
}
@@ -159,6 +171,7 @@ impl Button {
loading: false,
reverse: false,
bold: false,
cta: false,
children: Vec::new(),
loading_icon: None,
}
@@ -200,28 +213,38 @@ impl Button {
self
}
/// Set bold the button (label will be use the semi-bold font).
pub fn bold(mut self) -> Self {
self.bold = true;
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
/// Set the cta style of the button.
pub fn cta(mut self) -> Self {
self.cta = true;
self
}
/// Set the stop propagation of the button.
pub fn stop_propagation(mut self, val: bool) -> Self {
self.stop_propagation = val;
self
}
/// Set the loading icon of the button.
pub fn loading_icon(mut self, icon: impl Into<Icon>) -> Self {
self.loading_icon = Some(icon.into());
self
}
/// Set the click handler of the button.
pub fn on_click<C>(mut self, handler: C) -> Self
where
C: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
{
self.on_click = Some(Box::new(handler));
self
}
}
impl Disableable for Button {
@@ -280,7 +303,7 @@ impl RenderOnce for Button {
let normal_style = style.normal(window, cx);
let icon_size = match self.size {
Size::Size(v) => Size::Size(v * 0.75),
Size::Medium => Size::Small,
Size::Large => Size::Medium,
_ => self.size,
};
@@ -300,25 +323,67 @@ impl RenderOnce for Button {
// Icon Button
match self.size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_5(),
Size::Small => this.size_6(),
Size::Medium => this.size_7(),
_ => this.size_9(),
Size::XSmall => {
if self.cta {
this.w_10().h_5()
} else {
this.size_5()
}
}
Size::Small => {
if self.cta {
this.w_12().h_6()
} else {
this.size_6()
}
}
Size::Medium => {
if self.cta {
this.w_12().h_7()
} else {
this.size_7()
}
}
_ => {
if self.cta {
this.w_16().h_9()
} else {
this.size_9()
}
}
}
} else {
// Normal Button
match self.size {
Size::Size(size) => this.px(size * 0.2),
Size::XSmall => this.h_6().px_2(),
Size::Small => {
Size::XSmall => {
if self.icon.is_some() {
this.h_7().pl_2().pr_3()
this.h_6().pl_2().pr_2p5()
} else {
this.h_7().px_3()
this.h_6().px_2()
}
}
Size::Small => {
if self.icon.is_some() {
this.h_7().pl_2().pr_2p5()
} else {
this.h_7().px_2()
}
}
Size::Medium => {
if self.icon.is_some() {
this.h_8().pl_3().pr_3p5()
} else {
this.h_8().px_3()
}
}
Size::Large => {
if self.icon.is_some() {
this.h_10().px_3().pr_3p5()
} else {
this.h_10().px_3()
}
}
Size::Medium => this.h_8().px_3(),
Size::Large => this.h_10().px_4(),
}
}
})
@@ -346,17 +411,14 @@ impl RenderOnce for Button {
.shadow_none()
})
.child({
div()
.flex()
.when(self.reverse, |this| this.flex_row_reverse())
h_flex()
.id("label")
.items_center()
.when(self.reverse, |this| this.flex_row_reverse())
.justify_center()
.text_sm()
.map(|this| match self.size {
Size::XSmall => this.gap_0p5(),
Size::Small => this.gap_1(),
_ => this.gap_2().font_medium(),
Size::XSmall => this.text_xs().gap_1(),
Size::Small => this.text_sm().gap_1p5(),
_ => this.text_sm().gap_2(),
})
.when(!self.loading, |this| {
this.when_some(self.icon, |this, icon| {
@@ -421,8 +483,15 @@ impl ButtonVariant {
ButtonVariant::Secondary => cx.theme().elevated_surface_background,
ButtonVariant::Danger => cx.theme().danger_background,
ButtonVariant::Warning => cx.theme().warning_background,
ButtonVariant::Ghost { alt } => {
if *alt {
cx.theme().ghost_element_background_alt
} else {
cx.theme().ghost_element_background
}
}
ButtonVariant::Custom(colors) => colors.color,
_ => cx.theme().ghost_element_background,
_ => gpui::transparent_black(),
}
}
@@ -433,7 +502,13 @@ impl ButtonVariant {
ButtonVariant::Danger => cx.theme().danger_foreground,
ButtonVariant::Warning => cx.theme().warning_foreground,
ButtonVariant::Transparent => cx.theme().text_placeholder,
ButtonVariant::Ghost => cx.theme().text_muted,
ButtonVariant::Ghost { alt } => {
if *alt {
cx.theme().text
} else {
cx.theme().text_muted
}
}
ButtonVariant::Custom(colors) => colors.foreground,
}
}
@@ -444,14 +519,14 @@ impl ButtonVariant {
ButtonVariant::Secondary => cx.theme().secondary_hover,
ButtonVariant::Danger => cx.theme().danger_hover,
ButtonVariant::Warning => cx.theme().warning_hover,
ButtonVariant::Ghost => cx.theme().ghost_element_hover,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_hover,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.hover,
};
let fg = match self {
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Ghost => cx.theme().text,
ButtonVariant::Ghost { .. } => cx.theme().text,
ButtonVariant::Transparent => cx.theme().text_placeholder,
_ => self.text_color(window, cx),
};
@@ -465,7 +540,7 @@ impl ButtonVariant {
ButtonVariant::Secondary => cx.theme().secondary_active,
ButtonVariant::Danger => cx.theme().danger_active,
ButtonVariant::Warning => cx.theme().warning_active,
ButtonVariant::Ghost => cx.theme().ghost_element_active,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_active,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.active,
};
@@ -485,7 +560,7 @@ impl ButtonVariant {
ButtonVariant::Secondary => cx.theme().secondary_selected,
ButtonVariant::Danger => cx.theme().danger_selected,
ButtonVariant::Warning => cx.theme().warning_selected,
ButtonVariant::Ghost => cx.theme().ghost_element_selected,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_selected,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.active,
};
@@ -501,7 +576,9 @@ impl ButtonVariant {
fn disabled(&self, _window: &Window, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Ghost => cx.theme().ghost_element_disabled,
ButtonVariant::Danger => cx.theme().danger_disabled,
ButtonVariant::Warning => cx.theme().warning_disabled,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_disabled,
ButtonVariant::Secondary => cx.theme().secondary_disabled,
_ => cx.theme().element_disabled,
};

View File

@@ -1,34 +1,21 @@
use std::rc::Rc;
use std::sync::OnceLock;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, Action, App, AppContext, Corner, Element, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, WeakEntity,
Window,
div, px, App, AppContext, Corner, Element, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window,
};
use serde::Deserialize;
use theme::ActiveTheme;
use crate::button::{Button, ButtonVariants};
use crate::input::InputState;
use crate::popover::{Popover, PopoverContent};
use crate::Icon;
use crate::{Icon, Sizable, Size};
/// Emit a emoji to target input
#[derive(Action, PartialEq, Clone, Debug, Deserialize)]
#[action(namespace = emoji, no_json)]
pub struct EmitEmoji(pub SharedString);
static EMOJIS: OnceLock<Vec<SharedString>> = OnceLock::new();
#[derive(IntoElement)]
pub struct EmojiPicker {
icon: Option<Icon>,
anchor: Option<Corner>,
target_input: WeakEntity<InputState>,
emojis: Rc<Vec<SharedString>>,
}
impl EmojiPicker {
pub fn new(target_input: WeakEntity<InputState>) -> Self {
fn get_emojis() -> &'static Vec<SharedString> {
EMOJIS.get_or_init(|| {
let mut emojis: Vec<SharedString> = vec![];
emojis.extend(
@@ -38,9 +25,23 @@ impl EmojiPicker {
.collect::<Vec<SharedString>>(),
);
emojis
})
}
#[derive(IntoElement)]
pub struct EmojiPicker {
icon: Option<Icon>,
size: Size,
anchor: Option<Corner>,
target_input: WeakEntity<InputState>,
}
impl EmojiPicker {
pub fn new(target_input: WeakEntity<InputState>) -> Self {
Self {
target_input,
emojis: emojis.into(),
size: Size::default(),
anchor: None,
icon: None,
}
@@ -57,6 +58,13 @@ impl EmojiPicker {
}
}
impl Sizable for EmojiPicker {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for EmojiPicker {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
Popover::new("emoji-picker")
@@ -70,10 +78,10 @@ impl RenderOnce for EmojiPicker {
.trigger(
Button::new("emoji-trigger")
.when_some(self.icon, |this, icon| this.icon(icon))
.ghost(),
.ghost()
.with_size(self.size),
)
.content(move |window, cx| {
let emojis = self.emojis.clone();
let input = self.target_input.clone();
cx.new(|cx| {
@@ -83,7 +91,7 @@ impl RenderOnce for EmojiPicker {
.flex_wrap()
.items_center()
.gap_2()
.children(emojis.iter().map(|e| {
.children(get_emojis().iter().map(|e| {
div()
.id(e.clone())
.flex_auto()

View File

@@ -3,7 +3,6 @@ pub use focusable::FocusableCycle;
pub use icon::*;
pub use root::{ContextModal, Root};
pub use styled::*;
pub use title_bar::*;
pub use window_border::{window_border, WindowBorder};
pub use crate::Disableable;
@@ -39,7 +38,6 @@ mod focusable;
mod icon;
mod root;
mod styled;
mod title_bar;
mod window_border;
i18n::init!();

View File

@@ -45,7 +45,7 @@ impl Default for ModalButtonProps {
ok_text: None,
ok_variant: ButtonVariant::Primary,
cancel_text: None,
cancel_variant: ButtonVariant::Ghost,
cancel_variant: ButtonVariant::Ghost { alt: false },
}
}
}
@@ -450,12 +450,18 @@ impl RenderOnce for Modal {
.top(y)
.w(self.width)
.when_some(self.max_width, |this, w| this.max_w(w))
.child(h_flex().h_4().px_3().justify_center().when_some(
self.title,
|this, title| {
this.h_12().font_semibold().text_center().child(title)
},
))
.child(
div()
.px_2()
.h_4()
.w_full()
.flex()
.items_center()
.justify_center()
.when_some(self.title, |this, title| {
this.h_10().font_semibold().text_center().child(title)
}),
)
.when(self.show_close, |this| {
this.child(
Button::new("close")

View File

@@ -1,10 +1,11 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
div, AnyView, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement,
ParentElement as _, Render, Styled, Window,
div, AnyView, App, AppContext, Context, Decorations, Entity, FocusHandle, InteractiveElement,
IntoElement, ParentElement as _, Render, Styled, Window,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
use crate::input::InputState;
use crate::modal::Modal;
@@ -257,11 +258,29 @@ impl Render for Root {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let base_font_size = cx.theme().font_size;
let font_family = cx.theme().font_family.clone();
let decorations = window.window_decorations();
window.set_rem_size(base_font_size);
window_border().child(
div()
.id("root")
.map(|this| match decorations {
Decorations::Server => this,
Decorations::Client { tiling, .. } => this
.when(!(tiling.top || tiling.right), |el| {
el.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |el| {
el.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.bottom || tiling.right), |el| {
el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.bottom || tiling.left), |el| {
el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
}),
})
.relative()
.size_full()
.font_family(font_family)

View File

@@ -9,7 +9,8 @@ use gpui::{
IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
Position, ScrollHandle, ScrollWheelEvent, Size, UniformListScrollHandle, Window,
};
use theme::{ActiveTheme, ScrollBarMode};
use theme::scrollbar_mode::ScrollBarMode;
use theme::ActiveTheme;
use crate::AxisExt;

View File

@@ -18,6 +18,11 @@ pub fn v_flex() -> Div {
div().v_flex()
}
/// Returns a `Div` as divider.
pub fn divider(cx: &App) -> Div {
div().my_2().w_full().h_px().bg(cx.theme().border)
}
macro_rules! font_weight {
($fn:ident, $const:ident) => {
/// [docs](https://tailwindcss.com/docs/font-weight)

View File

@@ -233,7 +233,7 @@ impl Element for Switch {
.when_some(self.description.clone(), |this, description| {
this.child(
div()
.w_3_4()
.pr_2()
.text_xs()
.text_color(cx.theme().text_muted)
.child(description),

View File

@@ -1,391 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
black, div, px, relative, white, AnyElement, App, ClickEvent, Div, Element, Hsla,
InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, Rgba,
Stateful, StatefulInteractiveElement as _, Style, Styled, Window, WindowControlArea,
};
use theme::ActiveTheme;
use crate::{h_flex, Icon, IconName, InteractiveElementExt as _, Sizable as _};
const TITLE_BAR_HEIGHT: Pixels = px(34.);
#[cfg(target_os = "macos")]
const TITLE_BAR_LEFT_PADDING: Pixels = px(80.);
#[cfg(not(target_os = "macos"))]
const TITLE_BAR_LEFT_PADDING: Pixels = px(12.);
type OnCloseWindow = Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>;
/// TitleBar used to customize the appearance of the title bar.
///
/// We can put some elements inside the title bar.
#[derive(IntoElement)]
pub struct TitleBar {
base: Stateful<Div>,
children: Vec<AnyElement>,
on_close_window: OnCloseWindow,
}
impl TitleBar {
pub fn new() -> Self {
Self {
base: div().id("title-bar"),
children: Vec::new(),
on_close_window: None,
}
}
/// Add custom for close window event, default is None, then click X button will call `window.remove_window()`.
/// Linux only, this will do nothing on other platforms.
pub fn on_close_window(
mut self,
f: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
if cfg!(target_os = "linux") {
self.on_close_window = Some(Rc::new(Box::new(f)));
}
self
}
}
impl Default for TitleBar {
fn default() -> Self {
Self::new()
}
}
// The Windows control buttons have a fixed width of 35px.
//
// We don't need implementation the click event for the control buttons.
// If user clicked in the bounds, the window event will be triggered.
#[derive(IntoElement, Clone)]
enum ControlIcon {
Minimize,
Restore,
Maximize,
Close { on_close_window: OnCloseWindow },
}
impl ControlIcon {
fn minimize() -> Self {
Self::Minimize
}
fn restore() -> Self {
Self::Restore
}
fn maximize() -> Self {
Self::Maximize
}
fn close(on_close_window: OnCloseWindow) -> Self {
Self::Close { on_close_window }
}
fn id(&self) -> &'static str {
match self {
Self::Minimize => "minimize",
Self::Restore => "restore",
Self::Maximize => "maximize",
Self::Close { .. } => "close",
}
}
fn icon(&self) -> IconName {
match self {
Self::Minimize => IconName::WindowMinimize,
Self::Restore => IconName::WindowRestore,
Self::Maximize => IconName::WindowMaximize,
Self::Close { .. } => IconName::WindowClose,
}
}
fn window_control_area(&self) -> WindowControlArea {
match self {
Self::Minimize => WindowControlArea::Min,
Self::Restore | Self::Maximize => WindowControlArea::Max,
Self::Close { .. } => WindowControlArea::Close,
}
}
fn is_close(&self) -> bool {
matches!(self, Self::Close { .. })
}
fn fg(&self, cx: &App) -> Hsla {
if cx.theme().mode.is_dark() {
white()
} else {
black()
}
}
fn hover_fg(&self, cx: &App) -> Hsla {
if self.is_close() || cx.theme().mode.is_dark() {
white()
} else {
black()
}
}
fn hover_bg(&self, cx: &App) -> Rgba {
if self.is_close() {
Rgba {
r: 232.0 / 255.0,
g: 17.0 / 255.0,
b: 32.0 / 255.0,
a: 1.0,
}
} else if cx.theme().mode.is_dark() {
Rgba {
r: 0.9,
g: 0.9,
b: 0.9,
a: 0.1,
}
} else {
Rgba {
r: 0.1,
g: 0.1,
b: 0.1,
a: 0.2,
}
}
}
}
impl RenderOnce for ControlIcon {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let is_linux = cfg!(target_os = "linux");
let is_windows = cfg!(target_os = "windows");
let fg = self.fg(cx);
let hover_fg = self.hover_fg(cx);
let hover_bg = self.hover_bg(cx);
let icon = self.clone();
let on_close_window = match &self {
ControlIcon::Close { on_close_window } => on_close_window.clone(),
_ => None,
};
div()
.id(self.id())
.flex()
.w(TITLE_BAR_HEIGHT)
.h_full()
.justify_center()
.content_center()
.items_center()
.text_color(fg)
.when(is_windows, |this| {
this.window_control_area(self.window_control_area())
})
.when(is_linux, |this| {
this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.on_click(move |_, window, cx| {
cx.stop_propagation();
match icon {
Self::Minimize => window.minimize_window(),
Self::Restore | Self::Maximize => window.zoom_window(),
Self::Close { .. } => {
if let Some(f) = on_close_window.clone() {
f(&ClickEvent::default(), window, cx);
} else {
window.remove_window();
}
}
}
})
})
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
.active(|style| style.bg(hover_bg))
.child(Icon::new(self.icon()).small())
}
}
#[derive(IntoElement)]
struct WindowControls {
on_close_window: OnCloseWindow,
}
impl RenderOnce for WindowControls {
fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
if cfg!(target_os = "macos") {
return div().id("window-controls");
}
h_flex()
.id("window-controls")
.items_center()
.flex_shrink_0()
.h_full()
.child(
h_flex()
.justify_center()
.content_stretch()
.h_full()
.child(ControlIcon::minimize())
.child(if window.is_maximized() {
ControlIcon::restore()
} else {
ControlIcon::maximize()
}),
)
.child(ControlIcon::close(self.on_close_window))
}
}
impl Styled for TitleBar {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl ParentElement for TitleBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for TitleBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
const HEIGHT: Pixels = px(34.);
let is_linux = cfg!(target_os = "linux");
let is_macos = cfg!(target_os = "macos");
div().flex_shrink_0().child(
self.base
.flex()
.flex_row()
.items_center()
.justify_between()
.h(HEIGHT)
.bg(cx.theme().title_bar)
.when(window.is_fullscreen(), |this| this.pl(px(12.)))
.when(is_linux, |this| {
this.on_double_click(|_, window, _| window.zoom_window())
})
.when(is_macos, |this| {
this.on_double_click(|_, window, _| window.titlebar_double_click())
})
.child(
h_flex()
.id("bar")
.pl(TITLE_BAR_LEFT_PADDING)
.when(window.is_fullscreen(), |this| this.pl(px(12.)))
.window_control_area(WindowControlArea::Drag)
.justify_between()
.flex_shrink_0()
.flex_1()
.h_full()
.when(is_linux, |this| {
this.child(
div()
.top_0()
.left_0()
.absolute()
.size_full()
.h_full()
.child(TitleBarElement {}),
)
})
.children(self.children),
)
.child(WindowControls {
on_close_window: self.on_close_window,
}),
)
}
}
/// A TitleBar Element that can be move the window.
pub struct TitleBarElement {}
impl IntoElement for TitleBarElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for TitleBarElement {
type PrepaintState = ();
type RequestLayoutState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let style = Style {
flex_grow: 1.0,
flex_shrink: 1.0,
size: gpui::Size {
width: relative(1.).into(),
height: relative(1.).into(),
},
..Default::default()
};
let id = window.request_layout(style, [], cx);
(id, ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
_cx: &mut App,
) {
use gpui::{MouseButton, MouseMoveEvent, MouseUpEvent};
window.on_mouse_event(
move |ev: &MouseMoveEvent, _, window: &mut Window, _cx: &mut App| {
if bounds.contains(&ev.position) && ev.pressed_button == Some(MouseButton::Left) {
window.start_window_move();
}
},
);
window.on_mouse_event(
move |ev: &MouseUpEvent, _, window: &mut Window, _cx: &mut App| {
if ev.button == MouseButton::Left {
window.show_window_menu(ev.position);
}
},
);
}
}

View File

@@ -4,14 +4,9 @@ use gpui::{
HitboxBehavior, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels,
Point, RenderOnce, ResizeEdge, Size, Styled as _, Window,
};
use theme::ActiveTheme;
use theme::{CLIENT_SIDE_DECORATION_ROUNDING, CLIENT_SIDE_DECORATION_SHADOW};
pub(crate) const BORDER_SIZE: Pixels = Pixels(1.0);
pub(crate) const BORDER_RADIUS: Pixels = Pixels(0.0);
#[cfg(not(target_os = "linux"))]
pub(crate) const SHADOW_SIZE: Pixels = Pixels(0.0);
#[cfg(target_os = "linux")]
pub(crate) const SHADOW_SIZE: Pixels = Pixels(12.0);
const WINDOW_BORDER_WIDTH: Pixels = Pixels(1.0);
/// Create a new window border.
pub fn window_border() -> WindowBorder {
@@ -29,7 +24,7 @@ pub fn window_paddings(window: &Window, _cx: &App) -> Edges<Pixels> {
match window.window_decorations() {
Decorations::Server => Edges::all(px(0.0)),
Decorations::Client { tiling } => {
let mut paddings = Edges::all(SHADOW_SIZE);
let mut paddings = Edges::all(CLIENT_SIDE_DECORATION_SHADOW);
if tiling.top {
paddings.top = px(0.0);
}
@@ -62,9 +57,9 @@ impl ParentElement for WindowBorder {
}
impl RenderOnce for WindowBorder {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let decorations = window.window_decorations();
window.set_client_inset(SHADOW_SIZE);
window.set_client_inset(CLIENT_SIDE_DECORATION_SHADOW);
div()
.id("window-backdrop")
@@ -87,7 +82,9 @@ impl RenderOnce for WindowBorder {
move |_bounds, hitbox, window, _cx| {
let mouse = window.mouse_position();
let size = window.window_bounds().get_bounds().size;
let Some(edge) = resize_edge(mouse, SHADOW_SIZE, size) else {
let Some(edge) =
resize_edge(mouse, CLIENT_SIDE_DECORATION_SHADOW, size)
else {
return;
};
window.set_cursor_style(
@@ -113,20 +110,26 @@ impl RenderOnce for WindowBorder {
.absolute(),
)
.when(!(tiling.top || tiling.right), |div| {
div.rounded_tr(BORDER_RADIUS)
div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_tl(BORDER_RADIUS)
div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!tiling.top, |div| div.pt(SHADOW_SIZE))
.when(!tiling.bottom, |div| div.pb(SHADOW_SIZE))
.when(!tiling.left, |div| div.pl(SHADOW_SIZE))
.when(!tiling.right, |div| div.pr(SHADOW_SIZE))
.when(!(tiling.bottom || tiling.right), |div| {
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.bottom || tiling.left), |div| {
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!tiling.top, |div| div.pt(CLIENT_SIDE_DECORATION_SHADOW))
.when(!tiling.bottom, |div| div.pb(CLIENT_SIDE_DECORATION_SHADOW))
.when(!tiling.left, |div| div.pl(CLIENT_SIDE_DECORATION_SHADOW))
.when(!tiling.right, |div| div.pr(CLIENT_SIDE_DECORATION_SHADOW))
.on_mouse_down(MouseButton::Left, move |_, window, _cx| {
let size = window.window_bounds().get_bounds().size;
let pos = window.mouse_position();
if let Some(edge) = resize_edge(pos, SHADOW_SIZE, size) {
if let Some(edge) = resize_edge(pos, CLIENT_SIDE_DECORATION_SHADOW, size) {
window.start_window_resize(edge)
};
}),
@@ -137,17 +140,22 @@ impl RenderOnce for WindowBorder {
.map(|div| match decorations {
Decorations::Server => div,
Decorations::Client { tiling } => div
.border_color(cx.theme().window_border)
.when(!(tiling.top || tiling.right), |div| {
div.rounded_tr(BORDER_RADIUS)
div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_tl(BORDER_RADIUS)
div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!tiling.top, |div| div.border_t(BORDER_SIZE))
.when(!tiling.bottom, |div| div.border_b(BORDER_SIZE))
.when(!tiling.left, |div| div.border_l(BORDER_SIZE))
.when(!tiling.right, |div| div.border_r(BORDER_SIZE))
.when(!(tiling.bottom || tiling.right), |div| {
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.bottom || tiling.left), |div| {
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!tiling.top, |div| div.border_t(WINDOW_BORDER_WIDTH))
.when(!tiling.bottom, |div| div.border_b(WINDOW_BORDER_WIDTH))
.when(!tiling.left, |div| div.border_l(WINDOW_BORDER_WIDTH))
.when(!tiling.right, |div| div.border_r(WINDOW_BORDER_WIDTH))
.when(!tiling.is_tiled(), |div| {
div.shadow(vec![gpui::BoxShadow {
color: Hsla {
@@ -156,7 +164,7 @@ impl RenderOnce for WindowBorder {
l: 0.,
a: 0.3,
},
blur_radius: SHADOW_SIZE / 2.,
blur_radius: CLIENT_SIDE_DECORATION_SHADOW / 2.,
spread_radius: px(0.),
offset: point(px(0.0), px(0.0)),
}])

View File

@@ -5,25 +5,41 @@ common:
en: "Add"
update:
en: "Update"
upload:
en: "Upload"
change:
en: "Change"
continue:
en: "Continue"
pubkey:
en: "Public Key"
pubkey_invalid:
en: "Public Key is not valid"
secret:
en: "Secret Key"
not_found:
en: "Not Found"
room_error:
en: "Failed to open room. Please try again later."
preferences:
en: "Preferences"
allow:
en: "Allow"
logout:
en: "Logout"
copied:
en: "Copied"
saved:
en: "Your Secret Key has been saved"
clear:
en: "Clear"
user:
dark_mode:
en: "Dark Mode"
settings:
en: "Settings"
sign_out:
en: "Sign out"
welcome:
title:
en: "Welcome to Coop"
@@ -41,6 +57,12 @@ onboarding:
en: "Already have an account? Log in."
startup:
client_keys_warning:
en: "Warning"
client_keys_desc:
en: "Allow Coop to read the client keys stored in Keychain to continue"
create_new_keys:
en: "Create New Keys"
auto_login_in_progress:
en: "Auto login in progress"
stuck:
@@ -50,11 +72,23 @@ startup:
new_account:
title:
en: "Create New Account"
password_invalid:
en: "Password is invalid"
set_password_prompt:
en: "Set password to encrypt your key *"
en: "Create a new identity"
name:
en: "What should people call you?"
avatar:
en: "Choose an avatar to help people recognize you"
backup_label:
en: "Backup to avoid losing access to your account"
backup_description:
en: "In the Nostr Network, your account is defined by a Secret Key. This key is used to sign your messages and identify you."
backup_pubkey_note:
en: "Your Public Key is the address that others will use to find you on the Nostr Network."
backup_secret_note:
en: "Your Secret Key is required to access your account. If you lose it, you will lose access to your account."
backup_skip:
en: "Do it later"
backup_download:
en: "Download"
login:
title:
@@ -92,27 +126,21 @@ login:
logging_in:
en: "Logging in..."
chatspace:
create_new_keys:
en: "Create New Keys"
appearance_tooltip:
en: "Change the app's appearance"
preferences_title:
en: "Preferences"
preferences_tooltip:
en: "Open Preferences"
languages_tooltip:
en: "Change the app's language"
share_profile:
en: "Share Profile"
relays:
button_label:
en: "Configure the Messaging Relays to receive messages"
modal_title:
en: "Set Up Messaging Relays"
description:
en: "In order to receive messages from others, you need to setup at least one Messaging Relay. You can use the recommend relays or add more."
en: "In order to receive messages from others, you need to set up at least one Messaging Relay."
add_some_relays:
en: "Please add some relays."
invalid:
en: "Relay URL is not valid."
empty:
en: "You need to add at least 1 relay to receive messages."
recommended:
en: "Recommended:"
subject:
title:
@@ -191,16 +219,14 @@ profile:
en: "No bio."
preferences:
modal_relays_title:
en: "Edit your Messaging Relays"
media_description:
en: "Coop currently only supports NIP-96 media servers. If you're unsure, please keep the default value."
en: "Coop currently only supports NIP-96 media servers."
backup_description:
en: "When you send a message, Coop will also send it to your configured Messaging Relays. You can disable this if you want all sent messages to disappear when you log out."
en: "When you send a message, Coop will also forward it to your configured Messaging Relays. Disabling this will cause all messages sent during the current session to disappear when the app is closed."
screening_description:
en: "When opening a chat request, Coop will show a popup to help users verify the sender."
en: "When opening a chat request, Coop will show a popup to help you verify the sender."
bypass_description:
en: "Requests from user's contacts will automatically go to inbox."
en: "Requests from your contacts will automatically go to inbox."
hide_avatar_description:
en: "Unload all avatar pictures to improve performance and reduce memory usage."
proxy_description:
@@ -304,7 +330,7 @@ sidebar:
trusted_contacts_tooltip:
en: "Only show rooms from trusted contacts"
retrieving_messages:
en: "Retrieving messages..."
en: "Retrieving messages"
retrieving_messages_description:
en: "This may take some time"
why_seeing_this_tooltip: