7 Commits

Author SHA1 Message Date
fa1d5720b0 fix avatar 2026-08-01 11:10:22 +07:00
32a58211cc fix sidebar 2026-08-01 10:58:37 +07:00
4915d4fd60 fix tab bar 2026-08-01 10:52:38 +07:00
53f3e77a5d highlight active tab 2026-08-01 10:28:02 +07:00
e52d584e0c update 2026-08-01 10:15:15 +07:00
b0d1521c49 feat: add support for reaction (#37)
Reviewed-on: #37
2026-07-31 02:58:42 +00:00
9dbac5308d chore: update deps 2026-07-31 07:34:12 +07:00
33 changed files with 413 additions and 434 deletions

137
Cargo.lock generated
View File

@@ -202,11 +202,11 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "ar_archive_writer"
version = "0.5.2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348"
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
dependencies = [
"object",
"object 0.39.1",
]
[[package]]
@@ -635,7 +635,7 @@ dependencies = [
"cfg-if",
"libc",
"miniz_oxide",
"object",
"object 0.37.3",
"rustc-demangle",
"windows-link 0.2.1",
]
@@ -648,9 +648,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bech32"
version = "0.11.1"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8"
[[package]]
name = "bindgen"
@@ -1087,6 +1087,7 @@ dependencies = [
"nostr-sdk",
"person",
"pulldown-cmark",
"regex",
"serde",
"settings",
"smallvec",
@@ -1231,7 +1232,7 @@ dependencies = [
[[package]]
name = "collections"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"gpui_util",
"indexmap",
@@ -1715,7 +1716,7 @@ dependencies = [
[[package]]
name = "derive_refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"proc-macro2",
"quote",
@@ -2611,7 +2612,7 @@ dependencies = [
[[package]]
name = "gpui"
version = "0.2.2"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"accesskit",
"anyhow",
@@ -2693,7 +2694,7 @@ dependencies = [
[[package]]
name = "gpui_linux"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"accesskit",
"accesskit_unix",
@@ -2745,7 +2746,7 @@ dependencies = [
[[package]]
name = "gpui_macos"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"accesskit",
"accesskit_macos",
@@ -2794,7 +2795,7 @@ dependencies = [
[[package]]
name = "gpui_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -2805,7 +2806,7 @@ dependencies = [
[[package]]
name = "gpui_platform"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"console_error_panic_hook",
"gpui",
@@ -2818,7 +2819,7 @@ dependencies = [
[[package]]
name = "gpui_shared_string"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"schemars",
"serde",
@@ -2828,7 +2829,7 @@ dependencies = [
[[package]]
name = "gpui_tokio"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"anyhow",
"gpui",
@@ -2839,7 +2840,7 @@ dependencies = [
[[package]]
name = "gpui_util"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"anyhow",
"log",
@@ -2849,7 +2850,7 @@ dependencies = [
[[package]]
name = "gpui_web"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"anyhow",
"console_error_panic_hook",
@@ -2872,7 +2873,7 @@ dependencies = [
[[package]]
name = "gpui_wgpu"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"anyhow",
"bytemuck",
@@ -2902,7 +2903,7 @@ dependencies = [
[[package]]
name = "gpui_windows"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"accesskit",
"accesskit_windows",
@@ -3192,7 +3193,7 @@ dependencies = [
[[package]]
name = "http_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"anyhow",
"async-compression",
@@ -3212,7 +3213,7 @@ dependencies = [
[[package]]
name = "http_client_tls"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"rustls",
"rustls-platform-verifier",
@@ -3226,9 +3227,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.13"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
dependencies = [
"typenum",
]
@@ -4019,7 +4020,7 @@ dependencies = [
[[package]]
name = "media"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"anyhow",
"bindgen",
@@ -4212,8 +4213,8 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "nostr"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#c15db19db55df326d56bbe11543583e0bd0276e5"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
dependencies = [
"aes",
"base64",
@@ -4237,10 +4238,11 @@ dependencies = [
[[package]]
name = "nostr-blossom"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#c15db19db55df326d56bbe11543583e0bd0276e5"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
dependencies = [
"base64",
"bitcoin_hashes",
"nostr",
"opaquerr",
"reqwest",
@@ -4249,8 +4251,8 @@ dependencies = [
[[package]]
name = "nostr-connect"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#c15db19db55df326d56bbe11543583e0bd0276e5"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
dependencies = [
"async-utility",
"futures-core",
@@ -4263,8 +4265,8 @@ dependencies = [
[[package]]
name = "nostr-database"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#c15db19db55df326d56bbe11543583e0bd0276e5"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
dependencies = [
"btreecap",
"flatbuffers",
@@ -4274,8 +4276,8 @@ dependencies = [
[[package]]
name = "nostr-gossip"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#c15db19db55df326d56bbe11543583e0bd0276e5"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
dependencies = [
"nostr",
"opaquerr",
@@ -4283,8 +4285,8 @@ dependencies = [
[[package]]
name = "nostr-gossip-memory"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#c15db19db55df326d56bbe11543583e0bd0276e5"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
dependencies = [
"indexmap",
"lru",
@@ -4295,8 +4297,8 @@ dependencies = [
[[package]]
name = "nostr-lmdb"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#c15db19db55df326d56bbe11543583e0bd0276e5"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
dependencies = [
"async-utility",
"flume 0.12.0",
@@ -4309,8 +4311,8 @@ dependencies = [
[[package]]
name = "nostr-memory"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#c15db19db55df326d56bbe11543583e0bd0276e5"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
dependencies = [
"btreecap",
"nostr",
@@ -4320,8 +4322,8 @@ dependencies = [
[[package]]
name = "nostr-sdk"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#c15db19db55df326d56bbe11543583e0bd0276e5"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
dependencies = [
"async-utility",
"async-wsocket",
@@ -4333,6 +4335,7 @@ dependencies = [
"nostr-database",
"nostr-gossip",
"opaquerr",
"rand 0.10.2",
"tokio",
"tokio-stream",
"tracing",
@@ -4729,6 +4732,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -4922,7 +4934,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "perf"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"collections",
"serde",
@@ -4942,7 +4954,6 @@ dependencies = [
"nostr-sdk",
"smallvec",
"state",
"urlencoding",
]
[[package]]
@@ -5734,7 +5745,7 @@ dependencies = [
[[package]]
name = "refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"derive_refineable",
]
@@ -5816,7 +5827,7 @@ dependencies = [
[[package]]
name = "reqwest_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"anyhow",
"bytes",
@@ -6131,7 +6142,7 @@ dependencies = [
[[package]]
name = "scheduler"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"async-task",
"backtrace",
@@ -6757,7 +6768,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "sum_tree"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"heapless 0.9.3",
"log",
@@ -7710,12 +7721,6 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "usvg"
version = "0.46.0"
@@ -7758,7 +7763,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "util_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"perf",
"quote",
@@ -7797,9 +7802,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "value-bag"
version = "1.13.1"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef73bfbaf3216cb59c205d7176bee1194e0d84348979da31f4a71fefe3c2054e"
checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be"
dependencies = [
"value-bag-serde1",
"value-bag-sval2",
@@ -7807,9 +7812,9 @@ dependencies = [
[[package]]
name = "value-bag-serde1"
version = "1.13.1"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b92170db3db8a6354f12a5b7f13a5453928433e08fc46aee51eedfa8f7a28a1"
checksum = "417d6197dd0ee696783d6be4276ac6ea74b985e00024c85ccfb37aff4f2bed82"
dependencies = [
"erased-serde",
"serde_core",
@@ -7818,9 +7823,9 @@ dependencies = [
[[package]]
name = "value-bag-sval2"
version = "1.13.1"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf9832097ca044466ae3f1aa43a943d4e450b7c3b8cdf65363875df07da79a0"
checksum = "c61f7251ecde2c9ed431bbe0659853e7991753447447bbf1ae59d8b31c578d4e"
dependencies = [
"sval",
"sval_buffer",
@@ -9088,9 +9093,9 @@ dependencies = [
[[package]]
name = "xcursor"
version = "0.3.10"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
[[package]]
name = "xim-ctext"
@@ -9491,7 +9496,7 @@ dependencies = [
[[package]]
name = "zlog"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"anyhow",
"chrono",
@@ -9508,7 +9513,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "ztracing"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
dependencies = [
"tracing",
"tracing-subscriber",
@@ -9519,7 +9524,7 @@ dependencies = [
[[package]]
name = "ztracing_macro"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f851d82e880c152d0f41e4a60d363aa82b4b8114"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
[[package]]
name = "zune-core"

View File

@@ -1,62 +0,0 @@
[workspace]
resolver = "2"
members = ["crates/*", "desktop", "web"]
default-members = ["desktop"]
[workspace.package]
version = "1.0.0-beta5"
edition = "2024"
publish = false
[workspace.dependencies]
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { git = "https://github.com/zed-industries/zed" }
gpui_windows = { git = "https://github.com/zed-industries/zed" }
gpui_macos = { git = "https://github.com/zed-industries/zed" }
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
# Others
anyhow = "1.0.44"
chrono = { version = "0.4.38", features = ["wasmbind"] }
futures = "0.3"
itertools = "0.13.0"
log = "0.4"
oneshot = "0.1.10"
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
rust-embed = { version = "8.5", features = ["include-exclude"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
schemars = "1"
smallvec = "1.14.0"
smol = "2"
webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
errno = { version = "0.3.14", default-features = false }
instant = "0.1"
[patch.crates-io]
# Use stacker's psm version which may have better WASM support
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
[profile.profiling]
inherits = "release"
debug = true

120
README.md
View File

@@ -1,125 +1,5 @@
![Coop](/docs/coop.png)
<p>
<a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
<img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
</a>
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/coop">
</p>
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
### Screenshots
<p float="left">
<img src="/docs/mac_01.png" width="250" />
<img src="/docs/mac_02.png" width="250" />
<img src="/docs/mac_03.png" width="250" />
<img src="/docs/mac_04.png" width="250" />
<img src="/docs/mac_05.png" width="250" />
<img src="/docs/mac_06.png" width="250" />
<img src="/docs/mac_07.png" width="250" />
<img src="/docs/mac_08.png" width="250" />
<img src="/docs/mac_09.png" width="250" />
<img src="/docs/linux_01.png" width="250" />
<img src="/docs/linux_02.png" width="250" />
<img src="/docs/linux_03.png" width="250" />
<img src="/docs/linux_04.png" width="250" />
<img src="/docs/linux_05.png" width="250" />
</p>
### Installation
To install Coop, follow these steps:
1. **Download the Latest Release**:
- Visit the [Coop Releases page on GitHub](https://github.com/reyakov/coop/releases).
- Download the package that matches your operating system (Windows, macOS, or Linux).
2. **Install**:
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
3. **Run Coop**:
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
For more detailed instructions, refer to the [Release Notes](#) on GitHub.
### Developing Coop
Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nostr SDK](https://github.com/rust-nostr/nostr)
#### Prerequisites
- **Rust Toolchain**: Ensure you have Rust installed. If not, you can install it using [rustup](https://rustup.rs/).
- **Cargo**: Rust's package manager, which comes bundled with the Rust installation.
- **Git**: To clone the repository and manage version control.
#### Setting Up the Development Environment
1. Clone the repository:
```bash
git clone https://github.com/reyakov/coop.git
cd coop
```
2.1 Install Linux dependencies:
```bash
./script/linux
```
2.2 Install FreeBSD dependencies:
```bash
./script/freebsd
```
3. Install Rust dependencies:
```bash
cargo build
```
4. Run the app:
```bash
cargo run
```
#### Building for Production
To build Coop for production, use the following command:
```bash
cargo build --release
```
This will generate an optimized binary in the `target/release` directory.
#### Contributing Code
If you'd like to contribute to Coop, please follow these steps:
1. Fork the repository.
2. Create a new branch for your feature or bugfix.
3. Make your changes and ensure all tests pass.
4. Submit a pull request with a detailed description of your changes.
For more information, see the [Contributing](#contributing) section.
#### Additional Resources
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
- [GPUI](https://www.gpui.rs/)
- [GPUI Components](https://github.com/longbridge/gpui-component/)
- [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
### License
Copyright (C) 2025 Ren Amamiya & other Coop contributors

View File

@@ -1,5 +1,5 @@
use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet, hash_map};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, RwLock};
@@ -80,6 +80,9 @@ pub struct ChatRegistry {
/// Chat rooms
rooms: Vec<Entity<Room>>,
/// O(1) room lookup by room ID
room_index: HashMap<u64, Entity<Room>>,
/// Events that failed to unwrap for any reason
trash: Entity<BTreeSet<FailedMessage>>,
@@ -170,6 +173,7 @@ impl ChatRegistry {
Self {
rooms: vec![],
room_index: HashMap::new(),
trash: cx.new(|_| BTreeSet::default()),
seen: Arc::new(RwLock::new(HashMap::default())),
event_map: Arc::new(RwLock::new(HashMap::default())),
@@ -436,12 +440,9 @@ impl ChatRegistry {
self.tracking.load(Ordering::Acquire)
}
/// Get a weak reference to a room by its ID.
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
self.rooms
.iter()
.find(|this| &this.read(cx).id == id)
.map(|this| this.downgrade())
/// Get a weak reference to a room by its ID
pub fn room(&self, id: &u64, _cx: &App) -> Option<WeakEntity<Room>> {
self.room_index.get(id).map(|room| room.downgrade())
}
/// Get all rooms based on the filter.
@@ -511,7 +512,11 @@ impl ChatRegistry {
};
let room: Room = room.into().organize(&public_key);
self.rooms.insert(0, cx.new(|_| room));
let room_id = room.id;
let entity = cx.new(|_| room);
self.room_index.insert(room_id, entity.clone());
self.rooms.insert(0, entity);
cx.emit(ChatEvent::Ping);
cx.notify();
@@ -524,9 +529,11 @@ impl ChatRegistry {
// Get the room's ID.
let id = room.read(cx).id;
// If the room is new, add it to the registry.
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
self.rooms.insert(0, room.to_owned());
// If the room is new, add it to the registry and index.
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
let entity = room.to_owned();
e.insert(entity.clone());
self.rooms.insert(0, entity);
}
// Emit the open room event deferred to avoid re-entrant reads
@@ -537,17 +544,23 @@ impl ChatRegistry {
/// Close a room.
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
if self.rooms.iter().any(|r| r.read(cx).id == id) {
if self.room_index.contains_key(&id) {
self.room_index.remove(&id);
self.rooms.retain(|r| r.read(cx).id != id);
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(ChatEvent::CloseRoom(id));
});
}
}
/// Sort rooms by their created at.
/// Sort rooms by their created at. Only notifies if order changed.
pub fn sort(&mut self, cx: &mut Context<Self>) {
let before: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
cx.notify();
let after: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
if before != after {
cx.notify();
}
}
/// Finding rooms based on a query.
@@ -574,6 +587,7 @@ impl ChatRegistry {
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms.clear();
self.room_index.clear();
self.trash.update(cx, |this, cx| {
this.clear();
cx.notify();
@@ -601,7 +615,9 @@ impl ChatRegistry {
});
} else {
let new_room_id = new_room.id;
self.rooms.push(cx.new(|_| new_room));
let entity = cx.new(|_| new_room);
self.room_index.insert(new_room_id, entity.clone());
self.rooms.push(entity);
let new_index = self.rooms.len();
room_map.insert(new_room_id, new_index);
@@ -713,7 +729,7 @@ impl ChatRegistry {
return;
};
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
match self.room_index.get(&message.room).cloned() {
Some(room) => {
room.update(cx, |this, cx| {
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {

View File

@@ -1,11 +1,11 @@
use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
use instant::Duration;
use anyhow::{Error, anyhow};
use common::EventExt;
use device::DeviceRegistry;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use instant::Duration;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
@@ -271,8 +271,8 @@ impl Room {
}
/// Returns the members of the room
pub fn members(&self) -> Vec<PublicKey> {
self.members.clone()
pub fn members(&self) -> &[PublicKey] {
&self.members
}
/// Checks if the room has more than two members (group)
@@ -356,7 +356,7 @@ impl Room {
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let members = self.members();
let members = self.members().to_vec();
cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default()
@@ -419,12 +419,23 @@ impl Room {
}
// Construct a rumor event for direct message
pub fn rumor<S, I>(&self, content: S, replies: I, cx: &App) -> Option<UnsignedEvent>
pub fn rumor<S, I>(
&self,
content: S,
replies: I,
reaction: bool,
cx: &App,
) -> Option<UnsignedEvent>
where
S: Into<String>,
I: IntoIterator<Item = EventId>,
{
let kind = Kind::PrivateDirectMessage;
let kind = if reaction {
Kind::Reaction
} else {
Kind::PrivateDirectMessage
};
let content: String = content.into();
let replies: Vec<EventId> = replies.into_iter().collect();

View File

@@ -25,4 +25,5 @@ serde.workspace = true
linkify = "0.10.0"
pulldown-cmark = "0.13.1"
regex = "1"

View File

@@ -1,5 +1,5 @@
use std::collections::{BTreeMap, HashSet};
use std::sync::{Arc, RwLock};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, LazyLock, RwLock};
pub use actions::*;
use anyhow::{Context as AnyhowContext, Error};
@@ -17,6 +17,7 @@ use gpui::{
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use regex::Regex;
use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, upload};
@@ -35,6 +36,14 @@ use ui::{
use crate::text::RenderedText;
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
/// Regex matching strings that consist entirely of emoji characters,
/// zero-width joiners, variation selectors, and keycap combiners.
static EMOJI_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
mod actions;
mod text;
@@ -56,6 +65,12 @@ pub struct ChatPanel {
/// All messages (sorted by created_at)
messages: Vec<Message>,
/// O(1) message lookup by EventId
message_index: HashMap<EventId, usize>,
/// All reactions
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
/// Mapping message ids to their rendered texts
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
@@ -160,6 +175,8 @@ impl ChatPanel {
focus_handle: cx.focus_handle(),
id,
messages,
message_index: HashMap::new(),
reactions: BTreeMap::new(),
room,
list_state,
input,
@@ -224,23 +241,29 @@ impl ChatPanel {
while let Ok(status) = rx.recv_async().await {
{
let mut map = reports.write().unwrap();
for reports in map.values_mut() {
for report in reports.iter_mut() {
let status_id = match &*status {
SendStatus::Ok { id, .. } => *id,
SendStatus::Failed { id, .. } => *id,
};
// Find the matching report and update it (exit early on first match)
'outer: for reports_list in map.values_mut() {
for report in reports_list.iter_mut() {
let Some(output) = report.output.as_mut() else {
continue;
};
if *output.id() != status_id {
continue;
}
match &*status {
SendStatus::Ok { id, relay } => {
if output.id() == id {
output.success.insert(relay.clone(), EventSendStatus::Sent);
}
SendStatus::Ok { relay, .. } => {
output.success.insert(relay.clone(), EventSendStatus::Sent);
}
SendStatus::Failed { id, relay, message } => {
if output.id() == id {
output.failed.insert(relay.clone(), message.clone());
}
SendStatus::Failed { relay, message, .. } => {
output.failed.insert(relay.clone(), message.clone());
}
}
break 'outer;
}
}
}
@@ -259,7 +282,11 @@ impl ChatPanel {
move |this, _room, event, window, cx| {
match event {
RoomEvent::Incoming(message) => {
this.insert_message(message, false, cx);
if message.rumor.kind == Kind::Reaction {
this.insert_reaction(&message.rumor, cx);
} else {
this.insert_message(message, false, cx);
}
}
RoomEvent::Reload => {
// Defer to avoid re-entrant read on Room while
@@ -331,24 +358,60 @@ impl ChatPanel {
// Get the message which includes all attachments
let content = self.get_input_value(cx);
// Get the replies to this message
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
// Return if message is empty
if content.trim().is_empty() {
window.push_notification("Cannot send an empty message", cx);
return;
}
self.send_message(&content, window, cx);
// If replying to exactly one message with only a valid emoji,
// send as a reaction instead of a text message
if replies.len() == 1 && EMOJI_RE.is_match(&content) && self.attachments.read(cx).is_empty()
{
for reply in &replies {
self.send_reaction(&content, reply, window, cx);
}
self.clear(window, cx);
return;
}
self.send_message(&content, replies, false, window, cx);
}
fn send_reaction(
&mut self,
emoji: &str,
target: &EventId,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Return if emoji is empty
if emoji.trim().is_empty() {
window.push_notification("Cannot send an empty reaction", cx);
return;
}
self.send_message(emoji, vec![*target], true, window, cx);
}
/// Send a message to all members of the chat
fn send_message(&mut self, value: &str, window: &mut Window, cx: &mut Context<Self>) {
fn send_message(
&mut self,
value: &str,
replies: Vec<EventId>,
reaction: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if value.trim().is_empty() {
window.push_notification("Cannot send an empty message", cx);
return;
}
let room = self.room.clone();
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
let content = value.to_string();
let sent_ids = self.sent_ids.clone();
@@ -356,8 +419,10 @@ impl ChatPanel {
let Some(room_entity) = room.upgrade() else {
return;
};
// Create rumor and send task
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
let rumor = room.rumor(content.clone(), replies, cx)?;
let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?;
let send_task = room.send(rumor.clone(), cx)?;
Some((rumor, send_task))
}) {
@@ -371,9 +436,15 @@ impl ChatPanel {
let id = rumor.id.expect("rumor must have an id");
// Insert optimistic message and clear input
self.insert_message(&rumor, true, cx);
if rumor.kind != Kind::Reaction {
self.insert_message(&rumor, true, cx);
self.clear(window, cx);
} else {
self.insert_reaction(&rumor, cx);
}
// Update reports
self.insert_reports(id, vec![], cx);
self.clear(window, cx);
// Spawn a single task to await the send and update reports
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
@@ -428,6 +499,10 @@ impl ChatPanel {
if let Err(pos) = self.messages.binary_search(&msg) {
self.messages.insert(pos, msg);
// Rebuild message index after insertion (indices from pos to end shift)
for (i, message) in self.messages.iter().enumerate().skip(pos) {
self.message_index.insert(message.id, i);
}
self.list_state.splice(old_len..old_len, 1);
if scroll {
@@ -444,23 +519,56 @@ impl ChatPanel {
/// Convert and insert a vector of nostr events into the chat panel
fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) {
for event in events.iter() {
if event.kind == Kind::Reaction {
self.insert_reaction(event, cx);
continue;
}
// Bulk inserting messages, so no need to scroll to the latest message
self.insert_message(event, false, cx);
}
}
/// Check if a message has any reports
fn has_reports(&self, id: &EventId, _cx: &App) -> bool {
self.reports_by_id.read().unwrap().get(id).is_some()
/// Insert a reaction into the chat panel
fn insert_reaction(&mut self, event: &UnsignedEvent, cx: &mut Context<Self>) {
if event.kind != Kind::Reaction {
return;
}
for id in event.tags.event_ids() {
self.reactions
.entry(id)
.or_default()
.push((SharedString::from(&event.content), event.pubkey));
}
cx.notify();
}
fn sent_reports(&self, id: &EventId, _cx: &App) -> Option<Vec<SendReport>> {
/// Check if a message has any reports
fn has_reports(&self, id: &EventId) -> bool {
self.reports_by_id.read().unwrap().contains_key(id)
}
/// Clone reports for a message (used for modal display, not called during render)
fn sent_reports(&self, id: &EventId) -> Option<Vec<SendReport>> {
self.reports_by_id.read().unwrap().get(id).cloned()
}
/// Get a message by its ID
/// Get a message by its ID (O(1) lookup)
fn message(&self, id: &EventId) -> Option<&Message> {
self.messages.iter().find(|msg| &msg.id == id)
self.message_index
.get(id)
.and_then(|&ix| self.messages.get(ix))
}
/// Get a reaction by its target ID (returns reference, no allocation)
fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] {
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
}
/// Check if a message has any reactions
fn has_reaction(&self, id: &EventId) -> bool {
self.reactions.contains_key(id)
}
/// Scroll to a message by its ID
@@ -575,7 +683,10 @@ impl ChatPanel {
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::Insert(content) => {
self.send_message(content, window, cx);
self.input.update(cx, |this, cx| {
let new_value = format!("{} {}", this.value(), content);
this.set_value(new_value, window, cx);
});
}
Command::ChangeSubject(subject) => {
if self
@@ -834,7 +945,8 @@ impl ChatPanel {
let replies = message.replies_to.as_slice();
let has_replies = !replies.is_empty();
let has_reports = self.has_reports(&id, cx);
let has_reactions = self.has_reaction(&id);
let has_reports = self.has_reports(&id);
// Hide avatar setting
let hide_avatar = AppSettings::get_hide_avatar(cx);
@@ -879,12 +991,7 @@ impl ChatPanel {
.gap_2()
.text_sm()
.text_color(cx.theme().text_placeholder)
.child(
div()
.font_semibold()
.text_color(cx.theme().text)
.child(author.name()),
)
.child(div().font_semibold().child(author.name()))
.child(message.created_at.to_human_time())
.when(has_reports, |this| {
this.child(self.render_sent_reports(&id, cx))
@@ -895,7 +1002,10 @@ impl ChatPanel {
this.children(self.render_message_replies(replies, cx))
})
.child(rendered_text)
.child(self.render_media(&message.media, cx)),
.child(self.render_media(&message.media, cx))
.when(has_reactions, |this| {
this.child(self.render_reactions(&id, cx))
}),
),
)
.child(
@@ -990,13 +1100,9 @@ impl ChatPanel {
.w_full()
.px_2()
.border_l_2()
.border_color(cx.theme().element_selected)
.border_color(cx.theme().element_active)
.text_sm()
.child(
div()
.text_color(cx.theme().text_accent)
.child(author.name()),
)
.child(div().font_semibold().child(author.name()))
.child(
div()
.w_full()
@@ -1017,8 +1123,45 @@ impl ChatPanel {
items
}
fn render_reactions(&self, id: &EventId, cx: &App) -> impl IntoElement {
let current_user = NostrRegistry::global(cx).read(cx).current_user();
let reactions = self.reaction(id);
// Group reactions by emoji and collect authors for each
let mut grouped: BTreeMap<SharedString, Vec<PublicKey>> = BTreeMap::new();
for (emoji, author) in reactions {
grouped.entry(emoji.clone()).or_default().push(*author);
}
h_flex()
.mt_2()
.gap_1()
.children(grouped.into_iter().map(|(emoji, authors)| {
let count = authors.len();
let has_reacted = current_user
.map(|pk| authors.contains(&pk))
.unwrap_or(false);
h_flex()
.gap_2()
.py_0p5()
.px_1()
.rounded(cx.theme().radius)
.text_xs()
.border_1()
.when(has_reacted, |this| {
this.text_color(cx.theme().secondary_foreground)
.bg(cx.theme().secondary_background)
.border_color(cx.theme().secondary_active)
})
.when(!has_reacted, |this| this.border_color(cx.theme().border))
.child(emoji)
.child(SharedString::from(count.to_string()))
}))
}
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
let reports = self.sent_reports(id, cx);
let reports = self.sent_reports(id);
let pending = reports
.as_ref()
@@ -1192,6 +1335,29 @@ impl ChatPanel {
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.children({
let mut items = vec![];
for emoji in COMPACT_REACTION_EMOJIS {
items.push(
Button::new(*emoji)
.label(*emoji)
.tooltip(*emoji)
.small()
.ghost()
.on_click({
let emoji = *emoji;
let id = *id;
cx.listener(move |this, _event, window, cx| {
this.send_reaction(emoji, &id, window, cx);
})
}),
);
}
items
})
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
.child(
Button::new("reply")
.icon(IconName::Reply)
@@ -1305,7 +1471,7 @@ impl ChatPanel {
.gap_1()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Replying to:"))
.child("Replying to:")
.child(
div()
.text_color(cx.theme().text_accent)
@@ -1402,15 +1568,10 @@ impl ChatPanel {
.ghost()
.large()
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
this.horizontal()
.menu("👍", Box::new(Command::Insert("👍")))
.menu("👎", Box::new(Command::Insert("👎")))
.menu("😄", Box::new(Command::Insert("😄")))
.menu("🎉", Box::new(Command::Insert("🎉")))
.menu("😕", Box::new(Command::Insert("😕")))
.menu("❤️", Box::new(Command::Insert("❤️")))
.menu("🚀", Box::new(Command::Insert("🚀")))
.menu("👀", Box::new(Command::Insert("👀")))
let menu = this.horizontal();
REACTION_EMOJIS.iter().fold(menu, |this, emoji| {
this.menu(*emoji, Box::new(Command::Insert(emoji)))
})
})
}
}

View File

@@ -15,4 +15,3 @@ anyhow.workspace = true
smallvec.workspace = true
flume.workspace = true
log.workspace = true
urlencoding = "2.1.3"

View File

@@ -1,10 +1,10 @@
use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use instant::Duration;
use anyhow::{Error, anyhow};
use common::EventExt;
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};

View File

@@ -5,8 +5,6 @@ use gpui::SharedString;
use nostr_sdk::prelude::*;
use state::Announcement;
const IMAGE_RESIZER: &str = "https://wsrv.nl";
/// Person
#[derive(Debug, Clone)]
pub struct Person {
@@ -111,13 +109,7 @@ impl Person {
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| {
let encoded_picture = urlencoding::encode(picture);
let url = format!(
"{IMAGE_RESIZER}/?url={encoded_picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
);
url.into()
})
.map(|picture| picture.into())
.unwrap_or_else(|| "brand/avatar.png".into())
}

View File

@@ -4,6 +4,7 @@ use anyhow::Error;
use futures::io::AsyncReadExt;
use gpui::http_client::{AsyncBody, HttpClient};
use nostr_sdk::prelude::*;
use serde_json::Value;
#[allow(async_fn_in_trait)]
pub trait NostrAddress {

View File

@@ -51,7 +51,7 @@ impl Render for DragPanel {
.overflow_hidden()
.whitespace_nowrap()
.rounded(cx.theme().radius)
.text_xs()
.text_sm()
.text_color(cx.theme().text)
.text_ellipsis()
.when(cx.theme().shadow, |this| this.shadow_xs())
@@ -312,6 +312,7 @@ impl TabPanel {
cx.emit(PanelEvent::ZoomOut);
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
fn detach_panel(
@@ -321,10 +322,22 @@ impl TabPanel {
cx: &mut Context<Self>,
) {
let panel_view = panel.view();
let removed_ix = self.panels.iter().position(|p| p.view() == panel_view);
self.panels.retain(|p| p.view() != panel_view);
if self.active_ix >= self.panels.len() {
self.set_active_ix(self.panels.len().saturating_sub(1), window, cx)
} else if let Some(removed_ix) = removed_ix {
if removed_ix < self.active_ix {
self.active_ix = self.active_ix.saturating_sub(1);
} else if removed_ix == self.active_ix {
// The active panel was removed and another panel shifted into
// its position. Activate the new panel at the same index.
if let Some(new_active) = self.panels.get(self.active_ix) {
new_active.set_active(true, cx);
}
self.focus_active_panel(window, cx);
}
}
}
@@ -613,7 +626,7 @@ impl TabPanel {
div()
.w_full()
.text_ellipsis()
.text_xs()
.text_sm()
.child(panel.title(cx)),
)
.when(state.draggable, |this| {
@@ -687,8 +700,8 @@ impl TabPanel {
.on_click(cx.listener({
let panel = panel.clone();
move |view, _ev, window, cx| {
cx.stop_propagation();
view.remove_panel(&panel, window, cx);
view.set_active_ix(ix, window, cx);
}
})),
)

View File

@@ -1,5 +1,4 @@
use std::rc::Rc;
use instant::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -7,6 +6,7 @@ use gpui::{
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
};
use instant::Duration;
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm};
@@ -359,8 +359,8 @@ impl RenderOnce for Modal {
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
let x = bounds.center().x - self.width / 2.;
let mut padding_right = px(8.);
let mut padding_left = px(8.);
let mut padding_right = px(16.);
let mut padding_left = px(16.);
if let Some(pl) = self.style.padding.left {
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
@@ -452,8 +452,8 @@ impl RenderOnce for Modal {
.when_some(self.max_width, |this, w| this.max_w(w))
.child(
div()
.px_2()
.h_4()
.px_4()
.h_8()
.w_full()
.flex()
.items_center()

View File

@@ -183,12 +183,10 @@ impl RenderOnce for Tab {
.items_center()
.flex_shrink_0()
.h(TABBAR_HEIGHT)
.relative()
.overflow_hidden()
.text_color(fg)
.text_sm()
.when(!self.selected && !self.disabled, |this| {
this.hover(|this| this.text_color(cx.theme().secondary_foreground))
})
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
@@ -222,5 +220,21 @@ impl RenderOnce for Tab {
this.on_click(move |event, window, cx| on_click(event, window, cx))
})
})
.child(
div()
.absolute()
.bottom_0()
.left_0()
.right_0()
.h_0p5()
.when(self.selected && !self.disabled, |this| {
this.bg(cx.theme().element_active)
})
.when(!self.selected && !self.disabled, |this| {
this.invisible().group_hover("", |this| {
this.visible().bg(cx.theme().secondary_background)
})
}),
)
}
}

View File

@@ -16,7 +16,7 @@ use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
cx.new(|cx| Screening::new(public_key, window, cx))
@@ -263,7 +263,7 @@ impl Screening {
let contacts = contacts.clone();
let total = contacts.len();
this.title(SharedString::from("Mutual contacts")).child(
this.title("Mutual contacts").child(
v_flex().gap_1().pb_2().child(
uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
@@ -342,7 +342,7 @@ impl Render for Screening {
.h_7()
.justify_center()
.rounded_full()
.bg(cx.theme().surface_background)
.bg(cx.theme().elevated_surface_background)
.text_sm()
.truncate()
.text_ellipsis()
@@ -355,7 +355,8 @@ impl Render for Screening {
.gap_1()
.child(
Button::new("njump")
.label("View on njump.me")
.icon(IconName::Link)
.label("njump.me")
.secondary()
.small()
.rounded()
@@ -386,21 +387,18 @@ impl Render for Screening {
.text_sm()
.child(status_badge(Some(self.followed), cx))
.child(
v_flex()
.text_sm()
.child(SharedString::from("Contact"))
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
v_flex().text_sm().child("Contact").child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
),
)
.child(
@@ -415,7 +413,7 @@ impl Render for Screening {
.child(
h_flex()
.gap_0p5()
.child(SharedString::from("Activity on Public Relays"))
.child("Activity on Public Relays")
.child(
Button::new("active")
.icon(IconName::Info)
@@ -484,25 +482,8 @@ impl Render for Screening {
.gap_2()
.child(status_badge(Some(mutuals > 0), cx))
.child(
v_flex()
h_flex()
.text_sm()
.child(
h_flex()
.gap_0p5()
.child(SharedString::from("Mutual contacts"))
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.on_click(cx.listener(
move |this, _, window, cx| {
this.mutual_contacts(window, cx);
},
)),
),
)
.child(
div()
.line_clamp(1)
@@ -514,6 +495,17 @@ impl Render for Screening {
SharedString::from(NO_MUTUAL)
}
}),
)
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.disabled(mutuals == 0)
.on_click(cx.listener(move |this, _, window, cx| {
this.mutual_contacts(window, cx);
})),
),
),
),

View File

@@ -164,9 +164,7 @@ impl RenderOnce for RoomEntry {
)
.on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx);
// Prevent closing the modal on click
// modal will be automatically closed after closing panel
false
true
})
});
}

View File

@@ -354,6 +354,14 @@ impl Sidebar {
cx.notify();
});
self.new_requests = false;
// Reset search state when switching to inbox/requests
self.reset(window, cx);
// Clear the find input value
self.find_input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
}
fn render_list_items(

View File

@@ -1,50 +0,0 @@
[package]
name = "coop"
version.workspace = true
edition.workspace = true
publish.workspace = true
[[bin]]
name = "coop"
path = "src/main.rs"
[package.metadata.packager]
name = "Coop"
product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop"
category = "SocialNetworking"
version = "1.0.0-beta5"
out-dir = "../dist"
before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"]
icons = [
"resources/32x32.png",
"resources/128x128.png",
"resources/128x128@2x.png",
"resources/icon.icns",
"resources/icon.ico",
]
[dependencies]
assets = { path = "../crates/assets" }
workspace = { path = "../crates/workspace" }
ui = { path = "../crates/ui" }
theme = { path = "../crates/theme" }
common = { path = "../crates/common" }
state = { path = "../crates/state" }
device = { path = "../crates/device" }
chat = { path = "../crates/chat" }
settings = { path = "../crates/settings" }
auto_update = { path = "../crates/auto_update" }
person = { path = "../crates/person" }
gpui.workspace = true
gpui_platform.workspace = true
gpui_linux.workspace = true
gpui_windows.workspace = true
gpui_macos.workspace = true
reqwest_client.workspace = true
log.workspace = true
tracing-subscriber.workspace = true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 769 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB