diff --git a/Cargo.lock b/Cargo.lock index ce1f723..fd0e278 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.18" @@ -828,6 +834,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cbc" version = "0.1.2" @@ -944,10 +956,12 @@ dependencies = [ "gpui", "itertools 0.13.0", "log", + "nostr", "nostr-sdk", "oneshot", "smallvec", "smol", + "ui", ] [[package]] @@ -964,6 +978,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -988,9 +1029,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.32" +version = "4.5.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83" +checksum = "e2c80cae4c3350dd8f1272c73e83baff9a6ba550b8bfbe651b3c45b78cd1751e" dependencies = [ "clap_builder", "clap_derive", @@ -998,9 +1039,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.32" +version = "4.5.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8" +checksum = "0123e386f691c90aa228219b5b1ee72d465e8e231c79e9c82324f016a62a741c" dependencies = [ "anstream", "anstyle", @@ -1108,7 +1149,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" +source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1199,7 +1240,6 @@ dependencies = [ "global", "gpui", "itertools 0.13.0", - "keyring", "log", "nostr-connect", "nostr-sdk", @@ -1351,6 +1391,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1430,35 +1506,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" -[[package]] -name = "dbus" -version = "0.9.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" -dependencies = [ - "libc", - "libdbus-sys", - "winapi", -] - -[[package]] -name = "dbus-secret-service" -version = "4.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42a16374481d92aed73ae45b1f120207d8e71d24fb89f357fadbd8f946fd84b" -dependencies = [ - "aes", - "block-padding", - "cbc", - "dbus", - "futures-util", - "hkdf", - "num", - "once_cell", - "rand 0.8.5", - "sha2", -] - [[package]] name = "derive_more" version = "0.99.19" @@ -1475,7 +1522,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" +source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019" dependencies = [ "proc-macro2", "quote", @@ -1592,9 +1639,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "dwrote" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70182709525a3632b2ba96b6569225467b18ecb4a77f46d255f713a6bebf05fd" +checksum = "bfe1f192fcce01590bd8d839aca53ce0d11d803bf291b2a6c4ad925a8f0024be" dependencies = [ "lazy_static", "libc", @@ -2235,7 +2282,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" +source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2323,7 +2370,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" +source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019" dependencies = [ "proc-macro2", "quote", @@ -2435,6 +2482,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +[[package]] +name = "hermit-abi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" + [[package]] name = "hex" version = "0.4.3" @@ -2546,7 +2599,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" +source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019" dependencies = [ "anyhow", "bytes", @@ -2562,7 +2615,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" +source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019" dependencies = [ "rustls", "rustls-platform-verifier", @@ -2697,9 +2750,9 @@ dependencies = [ [[package]] name = "icu_locid_transform_data" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" [[package]] name = "icu_normalizer" @@ -2721,9 +2774,9 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" [[package]] name = "icu_properties" @@ -2742,9 +2795,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" [[package]] name = "icu_provider" @@ -2908,6 +2961,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi 0.5.0", + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "is-wsl" version = "0.4.0" @@ -2924,6 +2988,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -3010,18 +3083,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "keyring" -version = "4.0.0-rc.1" -source = "git+https://github.com/hwchen/keyring-rs#9d1b02ff4c9fd1ff125c71f252c14b9ed7313fcb" -dependencies = [ - "byteorder", - "dbus-secret-service", - "log", - "security-framework", - "windows-sys 0.59.0", -] - [[package]] name = "khronos-egl" version = "6.0.0" @@ -3069,15 +3130,6 @@ version = "0.2.171" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" -[[package]] -name = "libdbus-sys" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" -dependencies = [ - "pkg-config", -] - [[package]] name = "libfuzzer-sys" version = "0.4.9" @@ -3114,6 +3166,15 @@ dependencies = [ "libc", ] +[[package]] +name = "linkify" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" +dependencies = [ + "memchr", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3268,7 +3329,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" +source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019" dependencies = [ "anyhow", "bindgen 0.70.1", @@ -3447,7 +3508,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.40.0" -source = "git+https://github.com/rust-nostr/nostr#b513d781b55ec90b367ce58a0d91ead4e92242e7" +source = "git+https://github.com/rust-nostr/nostr#07306c9333c08bad5dc633fd97a59d26eb3d4443" dependencies = [ "aes", "base64", @@ -3472,7 +3533,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.40.0" -source = "git+https://github.com/rust-nostr/nostr#b513d781b55ec90b367ce58a0d91ead4e92242e7" +source = "git+https://github.com/rust-nostr/nostr#07306c9333c08bad5dc633fd97a59d26eb3d4443" dependencies = [ "async-utility", "nostr", @@ -3484,7 +3545,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.40.0" -source = "git+https://github.com/rust-nostr/nostr#b513d781b55ec90b367ce58a0d91ead4e92242e7" +source = "git+https://github.com/rust-nostr/nostr#07306c9333c08bad5dc633fd97a59d26eb3d4443" dependencies = [ "flatbuffers", "lru", @@ -3495,7 +3556,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.40.0" -source = "git+https://github.com/rust-nostr/nostr#b513d781b55ec90b367ce58a0d91ead4e92242e7" +source = "git+https://github.com/rust-nostr/nostr#07306c9333c08bad5dc633fd97a59d26eb3d4443" dependencies = [ "async-utility", "heed", @@ -3508,7 +3569,7 @@ dependencies = [ [[package]] name = "nostr-relay-pool" version = "0.40.0" -source = "git+https://github.com/rust-nostr/nostr#b513d781b55ec90b367ce58a0d91ead4e92242e7" +source = "git+https://github.com/rust-nostr/nostr#07306c9333c08bad5dc633fd97a59d26eb3d4443" dependencies = [ "async-utility", "async-wsocket", @@ -3525,7 +3586,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.40.0" -source = "git+https://github.com/rust-nostr/nostr#b513d781b55ec90b367ce58a0d91ead4e92242e7" +source = "git+https://github.com/rust-nostr/nostr#07306c9333c08bad5dc633fd97a59d26eb3d4443" dependencies = [ "async-utility", "nostr", @@ -3926,6 +3987,12 @@ dependencies = [ "zvariant", ] +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -4165,6 +4232,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.17.16" @@ -4317,9 +4412,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.37.2" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" +checksum = "bf763ab1c7a3aa408be466efc86efe35ed1bd3dd74173ed39d6b0d0a6f0ba148" dependencies = [ "memchr", ] @@ -4597,7 +4692,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" +source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019" dependencies = [ "derive_refineable", ] @@ -4726,7 +4821,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" +source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019" dependencies = [ "anyhow", "bytes", @@ -4947,9 +5042,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.0" +version = "0.103.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa4eeac2588ffff23e9d7a7e9b3f971c5fb5b7ebc9452745e0c232c64f83b2f" +checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" dependencies = [ "aws-lc-rs", "ring", @@ -5138,7 +5233,7 @@ checksum = "c2fdfc24bc566f839a2da4c4295b82db7d25a24253867d5c64355abb5799bdbe" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" +source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019" dependencies = [ "anyhow", "serde", @@ -5460,7 +5555,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" +source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019" dependencies = [ "arrayvec", "log", @@ -5830,6 +5925,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.9.0" @@ -6127,9 +6232,13 @@ version = "0.0.0" dependencies = [ "anyhow", "chrono", + "common", + "criterion", "gpui", "image", "itertools 0.13.0", + "linkify", + "nostr-sdk", "once_cell", "paste", "regex", @@ -6322,7 +6431,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" +source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019" dependencies = [ "anyhow", "async-fs", @@ -6375,9 +6484,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.10.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2" +checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" dependencies = [ "value-bag-serde1", "value-bag-sval2", @@ -6385,9 +6494,9 @@ dependencies = [ [[package]] name = "value-bag-serde1" -version = "1.10.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb773bd36fd59c7ca6e336c94454d9c66386416734817927ac93d81cb3c5b0b" +checksum = "35540706617d373b118d550d41f5dfe0b78a0c195dc13c6815e92e2638432306" dependencies = [ "erased-serde", "serde", @@ -6396,9 +6505,9 @@ dependencies = [ [[package]] name = "value-bag-sval2" -version = "1.10.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a916a702cac43a88694c97657d449775667bcd14b70419441d05b7fea4a83a" +checksum = "6fe7e140a2658cc16f7ee7a86e413e803fc8f9b5127adc8755c19f9fefa63a52" dependencies = [ "sval", "sval_buffer", diff --git a/Cargo.toml b/Cargo.toml index 1299537..f3a3b2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ gpui = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" } # Nostr +nostr = { git = "https://github.com/rust-nostr/nostr", features = ["parser"] } nostr-relay-builder = { 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", features = [ @@ -39,7 +40,6 @@ anyhow = "1.0.44" smallvec = "1.14.0" rust-embed = "8.5.0" log = "0.4" -keyring = { git = "https://github.com/hwchen/keyring-rs" } [profile.release] strip = true diff --git a/assets/brand/avatar.jpg b/assets/brand/avatar.jpg deleted file mode 100644 index d40b379..0000000 Binary files a/assets/brand/avatar.jpg and /dev/null differ diff --git a/assets/brand/avatar.png b/assets/brand/avatar.png new file mode 100644 index 0000000..0acd40d Binary files /dev/null and b/assets/brand/avatar.png differ diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 2da481d..5e920a1 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -30,7 +30,6 @@ log.workspace = true smallvec.workspace = true smol.workspace = true oneshot.workspace = true -keyring.workspace = true rustls = "0.23.23" futures = "0.3" diff --git a/crates/app/src/chat_space.rs b/crates/app/src/chat_space.rs index 0f8c63a..c16687c 100644 --- a/crates/app/src/chat_space.rs +++ b/crates/app/src/chat_space.rs @@ -2,8 +2,8 @@ use account::Account; use global::get_client; use gpui::{ actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis, - Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, - StyledImage, Subscription, Task, Window, + Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription, + Task, Window, }; use serde::Deserialize; use smallvec::{smallvec, SmallVec}; @@ -172,14 +172,7 @@ impl ChatSpace { .icon(Icon::new(IconName::ChevronDownSmall)) .when_some( Account::global(cx).read(cx).profile.as_ref(), - |this, profile| { - this.child( - img(profile.avatar.clone()) - .size_5() - .rounded_full() - .object_fit(ObjectFit::Cover), - ) - }, + |this, profile| this.child(img(profile.avatar.clone()).size_5()), ) .popup_menu(move |this, _, _cx| { this.menu( diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index c745618..bbe3b9a 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -40,6 +40,8 @@ enum Signal { fn main() { // Enable logging tracing_subscriber::fmt::init(); + // Fix crash on startup + _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let (event_tx, event_rx) = smol::channel::bounded::(1024); let (batch_tx, batch_rx) = smol::channel::bounded::>(100); @@ -55,10 +57,6 @@ fn main() { // Connect to default relays app.background_executor() .spawn(async { - // Fix crash on startup - // TODO: why this is needed? - _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - for relay in BOOTSTRAP_RELAYS.into_iter() { _ = client.add_relay(relay).await; } @@ -223,13 +221,15 @@ fn main() { let chats = cx.update(|_, cx| ChatRegistry::global(cx)).unwrap(); while let Ok(signal) = event_rx.recv().await { - cx.update(|_, cx| { + cx.update(|window, cx| { match signal { Signal::Eose => { - chats.update(cx, |this, cx| this.load_chat_rooms(cx)); + chats.update(cx, |this, cx| this.load_chat_rooms(window, cx)); } Signal::Event(event) => { - chats.update(cx, |this, cx| this.push_message(event, cx)); + chats.update(cx, |this, cx| { + this.push_message(event, window, cx) + }); } }; }) diff --git a/crates/app/src/views/chat.rs b/crates/app/src/views/chat.rs index 97fec07..1c63b25 100644 --- a/crates/app/src/views/chat.rs +++ b/crates/app/src/views/chat.rs @@ -1,89 +1,36 @@ -use anyhow::anyhow; +use anyhow::{anyhow, Error}; use async_utility::task::spawn; -use chats::{room::Room, ChatRegistry}; -use common::{ - last_seen::LastSeen, - profile::NostrProfile, - utils::{compare, nip96_upload}, -}; +use chats::{message::RoomMessage, room::Room, ChatRegistry}; +use common::utils::nip96_upload; use global::{constants::IMAGE_SERVICE, get_client}; use gpui::{ div, img, list, prelude::FluentBuilder, px, relative, svg, white, AnyElement, App, AppContext, Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, PathPromptOptions, Render, - SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, WeakEntity, - Window, + SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, Window, }; -use itertools::Itertools; use nostr_sdk::prelude::*; +use smallvec::{smallvec, SmallVec}; use smol::fs; -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use ui::{ button::{Button, ButtonRounded, ButtonVariants}, dock_area::panel::{Panel, PanelEvent}, input::{InputEvent, TextInput}, notification::Notification, popup_menu::PopupMenu, + text::RichText, theme::{scale::ColorScaleStep, ActiveTheme}, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt, }; const ALERT: &str = "has not set up Messaging (DM) Relays, so they will NOT receive your messages."; -const DESCRIPTION: &str = - "This conversation is private. Only members of this chat can see each other's messages."; -pub fn init( - id: &u64, - window: &mut Window, - cx: &mut App, -) -> Result>, anyhow::Error> { +pub fn init(id: &u64, window: &mut Window, cx: &mut App) -> Result>, Error> { if let Some(room) = ChatRegistry::global(cx).read(cx).get(id, cx) { Ok(Arc::new(Chat::new(id, room, window, cx))) } else { - Err(anyhow!("Chat room is not exist")) - } -} - -#[derive(PartialEq, Eq)] -struct ParsedMessage { - avatar: SharedString, - display_name: SharedString, - created_at: SharedString, - content: SharedString, -} - -impl ParsedMessage { - pub fn new(profile: &NostrProfile, content: &str, created_at: Timestamp) -> Self { - let content = SharedString::new(content); - let created_at = LastSeen(created_at).human_readable(); - - Self { - avatar: profile.avatar.clone(), - display_name: profile.name.clone(), - created_at, - content, - } - } -} - -#[derive(PartialEq, Eq)] -enum Message { - User(Box), - System(SharedString), - Placeholder, -} - -impl Message { - pub fn new(message: ParsedMessage) -> Self { - Self::User(Box::new(message)) - } - - pub fn system(content: SharedString) -> Self { - Self::System(content) - } - - pub fn placeholder() -> Self { - Self::Placeholder + Err(anyhow!("Chat Room not found.")) } } @@ -92,28 +39,22 @@ pub struct Chat { id: SharedString, focus_handle: FocusHandle, // Chat Room - room: WeakEntity, - messages: Entity>, - seens: Entity>, + room: Entity, + messages: Entity>, + text_data: HashMap, list_state: ListState, - #[allow(dead_code)] - subscriptions: Vec, // New Message input: Entity, - // Media + // Media Attachment attaches: Entity>>, is_uploading: bool, + #[allow(dead_code)] + subscriptions: SmallVec<[Subscription; 2]>, } impl Chat { - pub fn new( - id: &u64, - room: WeakEntity, - window: &mut Window, - cx: &mut App, - ) -> Entity { - let messages = cx.new(|_| vec![Message::placeholder()]); - let seens = cx.new(|_| vec![]); + pub fn new(id: &u64, room: Entity, window: &mut Window, cx: &mut App) -> Entity { + let messages = cx.new(|_| vec![RoomMessage::announcement()]); let attaches = cx.new(|_| None); let input = cx.new(|cx| { TextInput::new(window, cx) @@ -123,7 +64,7 @@ impl Chat { }); cx.new(|cx| { - let mut subscriptions = Vec::with_capacity(2); + let mut subscriptions = smallvec![]; subscriptions.push(cx.subscribe_in( &input, @@ -135,15 +76,21 @@ impl Chat { }, )); - if let Some(room) = room.upgrade() { - subscriptions.push(cx.subscribe_in( - &room, - window, - move |this: &mut Self, _, event, window, cx| { - this.push_message(&event.event, window, cx); - }, - )); - } + subscriptions.push(cx.subscribe_in( + &room, + window, + move |this, _, event, _window, cx| { + let old_len = this.messages.read(cx).len(); + let message = event.event.clone(); + + cx.update_entity(&this.messages, |this, cx| { + this.extend(vec![message]); + cx.notify(); + }); + + this.list_state.splice(old_len..old_len, 1); + }, + )); // Initialize list state // [item_count] always equal to 1 at the beginning @@ -161,9 +108,9 @@ impl Chat { focus_handle: cx.focus_handle(), is_uploading: false, id: id.to_string().into(), + text_data: HashMap::new(), room, messages, - seens, list_state, input, attaches, @@ -171,40 +118,37 @@ impl Chat { }; // Verify messaging relays of all members - this.verify_messaging_relays(cx); + this.verify_messaging_relays(window, cx); // Load all messages from database - this.load_messages(cx); + this.load_messages(window, cx); this }) } - fn verify_messaging_relays(&self, cx: &mut Context) { - let Some(model) = self.room.upgrade() else { - return; - }; + fn verify_messaging_relays(&self, window: &mut Window, cx: &mut Context) { + let room = self.room.read(cx); + let task = room.messaging_relays(cx); - let room = model.read(cx); - let task = room.verify_inbox_relays(cx); - - cx.spawn(async move |this, cx| { + cx.spawn_in(window, async move |this, cx| { if let Ok(result) = task.await { - cx.update(|cx| { - _ = this.update(cx, |this, cx| { + cx.update(|_, cx| { + this.update(cx, |this, cx| { result.into_iter().for_each(|item| { if !item.1 { - if let Ok(Some(member)) = + if let Some(profile) = this.room.read_with(cx, |this, _| this.member(&item.0)) { this.push_system_message( - format!("{} {}", member.name, ALERT), + format!("{} {}", profile.name, ALERT), cx, ); } } }); - }); + }) + .ok(); }) .ok(); } @@ -212,19 +156,27 @@ impl Chat { .detach(); } - fn load_messages(&self, cx: &mut Context) { - let Some(model) = self.room.upgrade() else { - return; - }; - - let room = model.read(cx); + fn load_messages(&self, window: &mut Window, cx: &mut Context) { + let room = self.room.read(cx); let task = room.load_messages(cx); - cx.spawn(async move |this, cx| { + cx.spawn_in(window, async move |this, cx| { if let Ok(events) = task.await { - cx.update(|cx| { + cx.update(|_, cx| { this.update(cx, |this, cx| { - this.push_messages(events, cx); + let old_len = this.messages.read(cx).len(); + let new_len = events.len(); + + // Extend the messages list with the new events + this.messages.update(cx, |this, cx| { + this.extend(events); + cx.notify(); + }); + + // Update list state with the new messages + this.list_state.splice(old_len..old_len, new_len); + + cx.notify(); }) .ok(); }) @@ -236,7 +188,7 @@ impl Chat { fn push_system_message(&self, content: String, cx: &mut Context) { let old_len = self.messages.read(cx).len(); - let message = Message::system(content.into()); + let message = RoomMessage::system(content.into()); cx.update_entity(&self.messages, |this, cx| { this.extend(vec![message]); @@ -246,89 +198,7 @@ impl Chat { self.list_state.splice(old_len..old_len, 1); } - fn push_message(&mut self, event: &Event, _window: &mut Window, cx: &mut Context) { - let Some(model) = self.room.upgrade() else { - return; - }; - - // Prevent duplicate messages - if self.seens.read(cx).iter().any(|id| id == &event.id) { - return; - } - // Add ID to seen list - self.seen(event.id, cx); - - let old_len = self.messages.read(cx).len(); - let room = model.read(cx); - - let profile = room - .member(&event.pubkey) - .unwrap_or(NostrProfile::new(event.pubkey, Metadata::default())); - - let message = Message::new(ParsedMessage::new( - &profile, - &event.content, - Timestamp::now(), - )); - - cx.update_entity(&self.messages, |this, cx| { - this.extend(vec![message]); - cx.notify(); - }); - - self.list_state.splice(old_len..old_len, 1); - } - - fn push_messages(&self, events: Events, cx: &mut Context) { - let Some(model) = self.room.upgrade() else { - return; - }; - - let room = model.read(cx); - let pubkeys = room.public_keys(); - let old_len = self.messages.read(cx).len(); - - let (messages, new_len) = { - let items: Vec = events - .into_iter() - .sorted_by_key(|ev| ev.created_at) - .filter_map(|ev| { - let mut other_pubkeys = ev.tags.public_keys().copied().collect::>(); - other_pubkeys.push(ev.pubkey); - - if !compare(&other_pubkeys, &pubkeys) { - return None; - } - - room.members - .iter() - .find(|m| m.public_key == ev.pubkey) - .map(|member| { - Message::new(ParsedMessage::new(member, &ev.content, ev.created_at)) - }) - }) - .collect(); - - // Used for update list state - let new_len = items.len(); - - (items, new_len) - }; - - cx.update_entity(&self.messages, |this, cx| { - this.extend(messages); - cx.notify(); - }); - - self.list_state.splice(old_len..old_len, new_len); - } - fn send_message(&mut self, window: &mut Window, cx: &mut Context) { - let Some(model) = self.room.upgrade() else { - return; - }; - - // Get message let mut content = self.input.read(cx).text().to_string(); // Get all attaches and merge its with message @@ -353,7 +223,7 @@ impl Chat { this.set_disabled(true, window, cx); }); - let room = model.read(cx); + let room = self.room.read(cx); let task = room.send_message(content, cx); cx.spawn_in(window, async move |this, cx| { @@ -384,8 +254,6 @@ impl Chat { } fn upload_media(&mut self, window: &mut Window, cx: &mut Context) { - let window_handle = window.window_handle(); - let paths = cx.prompt_for_paths(PathPromptOptions { files: true, directories: false, @@ -395,25 +263,24 @@ impl Chat { // Show loading spinner self.set_loading(true, cx); - // TODO: support multiple upload - cx.spawn(async move |this, cx| { + cx.spawn_in(window, async move |this, cx| { match Flatten::flatten(paths.await.map_err(|e| e.into())) { Ok(Some(mut paths)) => { let path = paths.pop().unwrap(); if let Ok(file_data) = fs::read(path).await { + let client = get_client(); let (tx, rx) = oneshot::channel::(); spawn(async move { - let client = get_client(); if let Ok(url) = nip96_upload(client, file_data).await { _ = tx.send(url); } }); if let Ok(url) = rx.await { - _ = cx.update_window(window_handle, |_, _, cx| { - _ = this.update(cx, |this, cx| { + cx.update(|_, cx| { + this.update(cx, |this, cx| { // Stop loading spinner this.set_loading(false, cx); @@ -425,8 +292,10 @@ impl Chat { } cx.notify(); }); - }); - }); + }) + .ok(); + }) + .ok(); } } } @@ -460,30 +329,32 @@ impl Chat { cx.notify(); } - fn seen(&mut self, id: EventId, cx: &mut Context) { - self.seens.update(cx, |this, cx| { - this.push(id); - cx.notify(); - }); - } - fn render_message( - &self, + &mut self, ix: usize, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - if let Some(message) = self.messages.read(cx).get(ix) { - div() - .group("") - .relative() - .flex() - .gap_3() - .w_full() - .p_2() - .map(|this| match message { - Message::User(item) => this - .hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE))) + const ROOM_DESCRIPTION: &str = + "This conversation is private. Only members of this chat can see each other's messages."; + + let message = self.messages.read(cx).get(ix).unwrap(); + let text_data = &mut self.text_data; + + div() + .group("") + .relative() + .flex() + .gap_3() + .w_full() + .p_2() + .map(|this| match message { + RoomMessage::User(item) => { + let text = text_data + .entry(item.id) + .or_insert_with(|| RichText::new(item.content.to_owned(), &item.mentions)); + + this.hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE))) .child( div() .absolute() @@ -496,12 +367,7 @@ impl Chat { this.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE)) }), ) - .child( - img(item.avatar.clone()) - .size_8() - .rounded_full() - .flex_shrink_0(), - ) + .child(img(item.author.avatar.clone()).size_8().flex_shrink_0()) .child( div() .flex() @@ -515,57 +381,60 @@ impl Chat { .gap_2() .text_xs() .child( - div().font_semibold().child(item.display_name.clone()), + div().font_semibold().child(item.author.name.clone()), ) - .child(div().child(item.created_at.clone()).text_color( - cx.theme().base.step(cx, ColorScaleStep::ELEVEN), - )), + .child( + div() + .child(item.created_at.human_readable()) + .text_color( + cx.theme() + .base + .step(cx, ColorScaleStep::ELEVEN), + ), + ), ) - .child(div().text_sm().child(item.content.clone())), - ), - Message::System(content) => this - .items_center() - .child( - div() - .absolute() - .left_0() - .top_0() - .w(px(2.)) - .h_full() - .bg(cx.theme().transparent) - .group_hover("", |this| this.bg(cx.theme().danger)), + .child(div().text_sm().child(text.element( + "body".into(), + window, + cx, + ))), ) - .child( - img("brand/avatar.jpg") - .size_8() - .rounded_full() - .flex_shrink_0(), - ) - .text_xs() - .text_color(cx.theme().danger) - .child(content.clone()), - Message::Placeholder => this - .w_full() - .h_32() - .flex() - .flex_col() - .items_center() - .justify_center() - .text_center() - .text_xs() - .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) - .line_height(relative(1.2)) - .child( - svg() - .path("brand/coop.svg") - .size_8() - .text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)), - ) - .child(DESCRIPTION), - }) - } else { - div() - } + } + RoomMessage::System(content) => this + .items_center() + .child( + div() + .absolute() + .left_0() + .top_0() + .w(px(2.)) + .h_full() + .bg(cx.theme().transparent) + .group_hover("", |this| this.bg(cx.theme().danger)), + ) + .child(img("brand/avatar.png").size_8().flex_shrink_0()) + .text_xs() + .text_color(cx.theme().danger) + .child(content.clone()), + RoomMessage::Announcement => this + .w_full() + .h_32() + .flex() + .flex_col() + .items_center() + .justify_center() + .text_center() + .text_xs() + .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) + .line_height(relative(1.3)) + .child( + svg() + .path("brand/coop.svg") + .size_8() + .text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)), + ) + .child(ROOM_DESCRIPTION), + }) } } @@ -575,37 +444,32 @@ impl Panel for Chat { } fn title(&self, cx: &App) -> AnyElement { - self.room - .read_with(cx, |this, _| { - let facepill: Vec = this - .members - .iter() - .map(|member| member.avatar.clone()) - .collect(); + self.room.read_with(cx, |this, _| { + let facepill: Vec = this + .members + .iter() + .map(|member| member.avatar.clone()) + .collect(); - div() - .flex() - .items_center() - .gap_1() - .child( - div() - .flex() - .flex_row_reverse() - .items_center() - .justify_start() - .children(facepill.into_iter().enumerate().rev().map(|(ix, face)| { - div().when(ix > 0, |div| div.ml_neg_1()).child( - img(face) - .size_4() - .rounded_full() - .object_fit(ObjectFit::Cover), - ) - })), - ) - .when_some(this.name(), |this, name| this.child(name)) - .into_any() - }) - .unwrap_or("Unnamed".into_any()) + div() + .flex() + .items_center() + .gap_1() + .child( + div() + .flex() + .flex_row_reverse() + .items_center() + .justify_start() + .children(facepill.into_iter().enumerate().rev().map(|(ix, face)| { + div() + .when(ix > 0, |div| div.ml_neg_1()) + .child(img(face).size_4()) + })), + ) + .when_some(this.name(), |this, name| this.child(name)) + .into_any() + }) } fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu { diff --git a/crates/app/src/views/new_account.rs b/crates/app/src/views/new_account.rs index 78afedb..fdb06e5 100644 --- a/crates/app/src/views/new_account.rs +++ b/crates/app/src/views/new_account.rs @@ -264,12 +264,7 @@ impl Render for NewAccount { .gap_2() .map(|this| { if self.avatar_input.read(cx).text().is_empty() { - this.child( - img("brand/avatar.jpg") - .size_10() - .rounded_full() - .flex_shrink_0(), - ) + this.child(img("brand/avatar.png").size_10().flex_shrink_0()) } else { this.child( img(format!( @@ -278,7 +273,6 @@ impl Render for NewAccount { self.avatar_input.read(cx).text() )) .size_10() - .rounded_full() .flex_shrink_0(), ) } diff --git a/crates/app/src/views/profile.rs b/crates/app/src/views/profile.rs index b657fdc..a859c85 100644 --- a/crates/app/src/views/profile.rs +++ b/crates/app/src/views/profile.rs @@ -296,12 +296,7 @@ impl Render for Profile { let picture = self.avatar_input.read(cx).text(); if picture.is_empty() { - this.child( - img("brand/avatar.jpg") - .size_10() - .rounded_full() - .flex_shrink_0(), - ) + this.child(img("brand/avatar.png").size_10().flex_shrink_0()) } else { this.child( img(format!( @@ -310,7 +305,6 @@ impl Render for Profile { self.avatar_input.read(cx).text() )) .size_10() - .rounded_full() .flex_shrink_0(), ) } diff --git a/crates/app/src/views/sidebar/mod.rs b/crates/app/src/views/sidebar/mod.rs index 86ab2f4..9054ae6 100644 --- a/crates/app/src/views/sidebar/mod.rs +++ b/crates/app/src/views/sidebar/mod.rs @@ -117,12 +117,7 @@ impl Sidebar { this.flex() .items_center() .gap_2() - .child( - img(member.avatar.clone()) - .size_6() - .rounded_full() - .flex_shrink_0(), - ) + .child(img(member.avatar.clone()).size_6().flex_shrink_0()) .child(member.name.clone()) }) } diff --git a/crates/chats/Cargo.toml b/crates/chats/Cargo.toml index b75f5dc..2a294a4 100644 --- a/crates/chats/Cargo.toml +++ b/crates/chats/Cargo.toml @@ -7,8 +7,10 @@ publish.workspace = true [dependencies] common = { path = "../common" } global = { path = "../global" } +ui = { path = "../ui" } gpui.workspace = true +nostr.workspace = true nostr-sdk.workspace = true anyhow.workspace = true itertools.workspace = true diff --git a/crates/chats/src/lib.rs b/crates/chats/src/lib.rs index 12dcfa5..0359ba8 100644 --- a/crates/chats/src/lib.rs +++ b/crates/chats/src/lib.rs @@ -3,12 +3,13 @@ use std::cmp::Reverse; use anyhow::anyhow; use common::{last_seen::LastSeen, utils::room_hash}; use global::get_client; -use gpui::{App, AppContext, Context, Entity, Global, Task, WeakEntity}; +use gpui::{App, AppContext, Context, Entity, Global, Task, Window}; use itertools::Itertools; use nostr_sdk::prelude::*; -use crate::room::{IncomingEvent, Room}; +use crate::room::Room; +pub mod message; pub mod room; pub fn init(cx: &mut App) { @@ -44,7 +45,7 @@ impl ChatRegistry { self.rooms.iter().map(|room| room.read(cx).id).collect() } - pub fn load_chat_rooms(&mut self, cx: &mut Context) { + pub fn load_chat_rooms(&mut self, window: &mut Window, cx: &mut Context) { let client = get_client(); let task: Task, Error>> = cx.background_spawn(async move { @@ -73,9 +74,9 @@ impl ChatRegistry { Ok(result) }); - cx.spawn(async move |this, cx| { + cx.spawn_in(window, async move |this, cx| { if let Ok(events) = task.await { - cx.update(|cx| { + cx.update(|_, cx| { this.update(cx, |this, cx| { if !events.is_empty() { let current_ids = this.current_rooms_ids(cx); @@ -118,11 +119,11 @@ impl ChatRegistry { self.is_loading } - pub fn get(&self, id: &u64, cx: &App) -> Option> { + pub fn get(&self, id: &u64, cx: &App) -> Option> { self.rooms .iter() .find(|model| model.read(cx).id == *id) - .map(|room| room.downgrade()) + .cloned() } pub fn push_room( @@ -144,14 +145,13 @@ impl ChatRegistry { } } - pub fn push_message(&mut self, event: Event, cx: &mut Context) { + pub fn push_message(&mut self, event: Event, window: &mut Window, cx: &mut Context) { let id = room_hash(&event); if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) { room.update(cx, |this, cx| { - this.last_seen = LastSeen(event.created_at); - cx.emit(IncomingEvent { event }); - cx.notify(); + this.set_last_seen(LastSeen(event.created_at), cx); + this.emit_message(event, window, cx); }); // Re-sort rooms by last seen diff --git a/crates/chats/src/message.rs b/crates/chats/src/message.rs new file mode 100644 index 0000000..6d0f96a --- /dev/null +++ b/crates/chats/src/message.rs @@ -0,0 +1,57 @@ +use common::{last_seen::LastSeen, profile::NostrProfile}; +use gpui::SharedString; +use nostr_sdk::prelude::*; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + pub id: EventId, + pub content: String, + pub author: NostrProfile, + pub mentions: Vec, + pub created_at: LastSeen, +} + +impl Message { + pub fn new( + id: EventId, + content: String, + author: NostrProfile, + mentions: Vec, + created_at: Timestamp, + ) -> Self { + let created_at = LastSeen(created_at); + + Self { + id, + content, + author, + mentions, + created_at, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RoomMessage { + /// User message + User(Box), + /// System message + System(SharedString), + /// Only use for UI purposes. + /// Placeholder will be used for display room announcement + Announcement, +} + +impl RoomMessage { + pub fn user(message: Message) -> Self { + Self::User(Box::new(message)) + } + + pub fn system(content: SharedString) -> Self { + Self::System(content) + } + + pub fn announcement() -> Self { + Self::Announcement + } +} diff --git a/crates/chats/src/room.rs b/crates/chats/src/room.rs index 0e62b77..90b4657 100644 --- a/crates/chats/src/room.rs +++ b/crates/chats/src/room.rs @@ -1,15 +1,22 @@ -use std::collections::HashSet; +use std::{collections::HashSet, sync::Arc}; use anyhow::Error; -use common::{last_seen::LastSeen, profile::NostrProfile, utils::room_hash}; +use common::{ + last_seen::LastSeen, + profile::NostrProfile, + utils::{compare, room_hash}, +}; use global::get_client; -use gpui::{App, AppContext, Entity, EventEmitter, SharedString, Task}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Task, Window}; +use itertools::Itertools; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; +use crate::message::{Message, RoomMessage}; + #[derive(Debug, Clone)] pub struct IncomingEvent { - pub event: Event, + pub event: RoomMessage, } pub struct Room { @@ -18,7 +25,7 @@ pub struct Room { /// Subject of the room pub name: Option, /// All members of the room - pub members: SmallVec<[NostrProfile; 2]>, + pub members: Arc>, } impl EventEmitter for Room {} @@ -44,18 +51,19 @@ impl Room { // Create a task for loading metadata let load_metadata = Self::load_metadata(event, cx); + // Create a new GPUI's Entity cx.new(|cx| { let this = Self { id, last_seen, name, - members: smallvec![], + members: Arc::new(smallvec![]), }; cx.spawn(async move |this, cx| { if let Ok(profiles) = load_metadata.await { - _ = cx.update(|cx| { - _ = this.update(cx, |this: &mut Room, cx| { + cx.update(|cx| { + this.update(cx, |this: &mut Room, cx| { // Update the room's name if it's not already set if this.name.is_none() { let mut name = profiles @@ -71,12 +79,16 @@ impl Room { this.name = Some(name.into()) }; - // Update the room's members - this.members.extend(profiles); + + let mut new_members = SmallVec::new(); + new_members.extend(profiles); + this.members = Arc::new(new_members); cx.notify(); - }); - }); + }) + .ok(); + }) + .ok(); } }) .detach(); @@ -122,13 +134,19 @@ impl Room { self.last_seen } + /// Set room's last seen + pub fn set_last_seen(&mut self, last_seen: LastSeen, cx: &mut Context) { + self.last_seen = last_seen; + cx.notify(); + } + /// Get room's last seen as ago format pub fn ago(&self) -> SharedString { self.last_seen.ago() } - /// Sync inbox relays for all room's members - pub fn verify_inbox_relays(&self, cx: &App) -> Task, Error>> { + /// Verify messaging_relays for all room's members + pub fn messaging_relays(&self, cx: &App) -> Task, Error>> { let client = get_client(); let pubkeys = self.public_keys(); @@ -157,8 +175,6 @@ impl Room { } /// Send message to all room's members - /// - /// NIP-4e: Message will be signed by the device signer pub fn send_message(&self, content: String, cx: &App) -> Task, Error>> { let client = get_client(); let pubkeys = self.public_keys(); @@ -192,21 +208,149 @@ impl Room { }) } - /// Load metadata for all members - pub fn load_messages(&self, cx: &App) -> Task> { + /// Load room messages + pub fn load_messages(&self, cx: &App) -> Task, Error>> { let client = get_client(); let pubkeys = self.public_keys(); + let members = Arc::clone(&self.members); + let filter = Filter::new() .kind(Kind::PrivateDirectMessage) - .authors(pubkeys.iter().copied()) - .pubkeys(pubkeys); + .authors(pubkeys.clone()) + .pubkeys(pubkeys.clone()); cx.background_spawn(async move { - let query = client.database().query(filter).await?; - Ok(query) + let mut messages = vec![]; + let parser = NostrParser::new(); + + // Get all events from database + let events = client + .database() + .query(filter) + .await? + .into_iter() + .sorted_by_key(|ev| ev.created_at) + .filter(|ev| { + let mut other_pubkeys = ev.tags.public_keys().copied().collect::>(); + other_pubkeys.push(ev.pubkey); + // Check if the event is from a member of the room + compare(&other_pubkeys, &pubkeys) + }) + .collect::>(); + + for event in events.into_iter() { + let mut mentions = vec![]; + let content = event.content.clone(); + let tokens = parser.parse(&content); + + let author = members + .iter() + .find(|profile| profile.public_key == event.pubkey) + .cloned() + .unwrap_or_else(|| NostrProfile::new(event.pubkey, Metadata::default())); + + let pubkey_tokens = tokens + .filter_map(|token| match token { + Token::Nostr(nip21) => match nip21 { + Nip21::Pubkey(pubkey) => Some(pubkey), + Nip21::Profile(profile) => Some(profile.public_key), + _ => None, + }, + _ => None, + }) + .collect::>(); + + for pubkey in pubkey_tokens { + if let Some(profile) = + members.iter().find(|profile| profile.public_key == pubkey) + { + mentions.push(profile.clone()); + } else { + let metadata = client + .database() + .metadata(pubkey) + .await? + .unwrap_or_default(); + + mentions.push(NostrProfile::new(pubkey, metadata)); + } + } + + let message = Message::new(event.id, content, author, mentions, event.created_at); + let room_message = RoomMessage::user(message); + + messages.push(room_message); + } + + Ok(messages) }) } + /// Emit message to GPUI + pub fn emit_message(&self, event: Event, window: &mut Window, cx: &mut Context) { + let client = get_client(); + let members = Arc::clone(&self.members); + + let task: Task> = cx.background_spawn(async move { + let parser = NostrParser::new(); + let content = event.content.clone(); + let tokens = parser.parse(&content); + let mut mentions = vec![]; + + let author = members + .iter() + .find(|profile| profile.public_key == event.pubkey) + .cloned() + .unwrap_or_else(|| NostrProfile::new(event.pubkey, Metadata::default())); + + let pubkey_tokens = tokens + .filter_map(|token| match token { + Token::Nostr(nip21) => match nip21 { + Nip21::Pubkey(pubkey) => Some(pubkey), + Nip21::Profile(profile) => Some(profile.public_key), + _ => None, + }, + _ => None, + }) + .collect::>(); + + for pubkey in pubkey_tokens { + if let Some(profile) = members + .iter() + .find(|profile| profile.public_key == event.pubkey) + { + mentions.push(profile.clone()); + } else { + let metadata = client + .database() + .metadata(pubkey) + .await? + .unwrap_or_default(); + + mentions.push(NostrProfile::new(pubkey, metadata)); + } + } + + let message = Message::new(event.id, content, author, mentions, event.created_at); + let room_message = RoomMessage::user(message); + + Ok(room_message) + }); + + cx.spawn_in(window, async move |this, cx| { + if let Ok(message) = task.await { + cx.update(|_, cx| { + this.update(cx, |_, cx| { + cx.emit(IncomingEvent { event: message }); + }) + .ok(); + }) + .ok(); + } + }) + .detach(); + } + /// Load metadata for all members fn load_metadata(event: &Event, cx: &App) -> Task, Error>> { let client = get_client(); @@ -219,18 +363,23 @@ impl Room { cx.background_spawn(async move { let signer = client.signer().await?; let signer_pubkey = signer.get_public_key().await?; - let mut profiles = vec![]; + let mut profiles = Vec::with_capacity(pubkeys.len()); for public_key in pubkeys.into_iter() { - if let Ok(result) = client.database().metadata(public_key).await { - let metadata = result.unwrap_or_default(); - let profile = NostrProfile::new(public_key, metadata); + let metadata = client + .database() + .metadata(public_key) + .await? + .unwrap_or_default(); - if public_key == signer_pubkey { - profiles.push(profile); - } else { - profiles.insert(0, profile); - } + // Convert metadata to profile + let profile = NostrProfile::new(public_key, metadata); + + if public_key == signer_pubkey { + // Room's owner always push to the end of the vector + profiles.push(profile); + } else { + profiles.insert(0, profile); } } diff --git a/crates/common/src/profile.rs b/crates/common/src/profile.rs index 700fb27..5b3d7b3 100644 --- a/crates/common/src/profile.rs +++ b/crates/common/src/profile.rs @@ -33,7 +33,7 @@ impl NostrProfile { ) .into() }) - .unwrap_or_else(|| "brand/avatar.jpg".into()) + .unwrap_or_else(|| "brand/avatar.png".into()) } fn extract_name(public_key: &PublicKey, metadata: &Metadata) -> SharedString { diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 7f7756e..6326038 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true publish.workspace = true [dependencies] +common = { path = "../common" } + +nostr-sdk.workspace = true gpui.workspace = true smol.workspace = true serde.workspace = true @@ -20,3 +23,11 @@ unicode-segmentation = "1.12.0" uuid = "1.10" once_cell = "1.19.0" image = "0.25.1" +linkify = "0.10.0" + +[dev-dependencies] +criterion = "0.5" + +[[bench]] +name = "text_benchmark" +harness = false diff --git a/crates/ui/benches/text_benchmark.rs b/crates/ui/benches/text_benchmark.rs new file mode 100644 index 0000000..df4b31c --- /dev/null +++ b/crates/ui/benches/text_benchmark.rs @@ -0,0 +1,142 @@ +use common::profile::NostrProfile; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use gpui::SharedString; +use nostr_sdk::prelude::*; +use ui::text::render_plain_text_mut; + +fn create_test_profiles() -> Vec { + let mut profiles = Vec::new(); + + // Create a few test profiles + for i in 0..5 { + let keypair = Keys::generate(); + let profile = NostrProfile { + public_key: keypair.public_key(), + name: SharedString::from(format!("user{}", i)), + avatar: SharedString::from(format!("avatar{}", i)), + // Add other required fields based on NostrProfile definition + // This is a simplified version - adjust based on your actual NostrProfile struct + }; + profiles.push(profile); + } + + profiles +} + +fn benchmark_plain_text(c: &mut Criterion) { + let profiles = create_test_profiles(); + + // Simple text without any links or entities + let simple_text = "This is a simple text message without any links or entities."; + + // Text with URLs + let text_with_urls = + "Check out https://example.com and https://nostr.com for more information."; + + // Text with nostr entities + let text_with_nostr = "I found this note nostr:note1qw5uy7hsqs4jcsvmjc2rj5t6f5uuenwg3yapm5l58srprspvshlspr4mh3 from npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft"; + + // Mixed content with urls and nostr entities + let mixed_content = "Check out https://example.com and my profile nostr:npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft along with this event nevent1qw5uy7hsqs4jcsvmjc2rj5t6f5uuenwg3yapm5l58srprspvshlspr4mh3"; + + // Long text with multiple links and entities + let long_text = "Here's a long message with multiple links like https://example1.com, https://example2.com, and https://example3.com. It also has nostr entities like npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft, note1qw5uy7hsqs4jcsvmjc2rj5t6f5uuenwg3yapm5l58srprspvshlspr4mh3, and nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuerpd46hxtnfdupzp8xummjw3exgcnqvmpw35xjueqvdnyqystngfxk5hsnfd9h8jtr8a4klacnp".repeat(3); + + // Benchmark with simple text + c.bench_function("render_plain_text_simple", |b| { + b.iter(|| { + let mut text = String::new(); + let mut highlights = Vec::new(); + let mut link_ranges = Vec::new(); + let mut link_urls = Vec::new(); + + render_plain_text_mut( + black_box(simple_text), + black_box(&profiles), + &mut text, + &mut highlights, + &mut link_ranges, + &mut link_urls, + ) + }) + }); + + // Benchmark with URLs + c.bench_function("render_plain_text_urls", |b| { + b.iter(|| { + let mut text = String::new(); + let mut highlights = Vec::new(); + let mut link_ranges = Vec::new(); + let mut link_urls = Vec::new(); + + render_plain_text_mut( + black_box(text_with_urls), + black_box(&profiles), + &mut text, + &mut highlights, + &mut link_ranges, + &mut link_urls, + ) + }) + }); + + // Benchmark with nostr entities + c.bench_function("render_plain_text_nostr", |b| { + b.iter(|| { + let mut text = String::new(); + let mut highlights = Vec::new(); + let mut link_ranges = Vec::new(); + let mut link_urls = Vec::new(); + + render_plain_text_mut( + black_box(text_with_nostr), + black_box(&profiles), + &mut text, + &mut highlights, + &mut link_ranges, + &mut link_urls, + ) + }) + }); + + // Benchmark with mixed content + c.bench_function("render_plain_text_mixed", |b| { + b.iter(|| { + let mut text = String::new(); + let mut highlights = Vec::new(); + let mut link_ranges = Vec::new(); + let mut link_urls = Vec::new(); + + render_plain_text_mut( + black_box(mixed_content), + black_box(&profiles), + &mut text, + &mut highlights, + &mut link_ranges, + &mut link_urls, + ) + }) + }); + + // Benchmark with long text + c.bench_function("render_plain_text_long", |b| { + b.iter(|| { + let mut text = String::new(); + let mut highlights = Vec::new(); + let mut link_ranges = Vec::new(); + let mut link_urls = Vec::new(); + + render_plain_text_mut( + black_box(&long_text), + black_box(&profiles), + &mut text, + &mut highlights, + &mut link_ranges, + &mut link_urls, + ) + }) + }); +} + +criterion_group!(benches, benchmark_plain_text); +criterion_main!(benches); diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index b928617..e6de94f 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -22,6 +22,7 @@ pub mod scroll; pub mod skeleton; pub mod switch; pub mod tab; +pub mod text; pub mod theme; pub mod tooltip; diff --git a/crates/ui/src/scroll/scrollable_mask.rs b/crates/ui/src/scroll/scrollable_mask.rs index 49373f9..a646c30 100644 --- a/crates/ui/src/scroll/scrollable_mask.rs +++ b/crates/ui/src/scroll/scrollable_mask.rs @@ -1,7 +1,7 @@ use gpui::{ - px, relative, App, Axis, Bounds, ContentMask, Corners, Edges, Element, ElementId, EntityId, - GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, Point, - Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window, + px, relative, App, Axis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId, + EntityId, GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, + Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window, }; use crate::AxisExt; @@ -111,6 +111,7 @@ impl Element for ScrollableMask { bounds, border_widths: Edges::all(px(1.0)), border_color: color, + border_style: BorderStyle::Solid, background: gpui::transparent_white().into(), corner_radii: Corners::all(px(0.)), }); diff --git a/crates/ui/src/scroll/scrollbar.rs b/crates/ui/src/scroll/scrollbar.rs index 6a4c7fb..c1b2ee2 100644 --- a/crates/ui/src/scroll/scrollbar.rs +++ b/crates/ui/src/scroll/scrollbar.rs @@ -1,7 +1,7 @@ use gpui::{ - fill, point, px, relative, App, Bounds, ContentMask, CursorStyle, Edges, Element, EntityId, - Hitbox, Hsla, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, - Point, Position, ScrollHandle, ScrollWheelEvent, UniformListScrollHandle, Window, + fill, point, px, relative, App, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element, + EntityId, Hitbox, Hsla, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, + Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, UniformListScrollHandle, Window, }; use serde::{Deserialize, Serialize}; use std::{ @@ -657,7 +657,7 @@ impl Element for Scrollbar { let margin_end = state.margin_end; let is_vertical = axis.is_vertical(); - window.set_cursor_style(CursorStyle::default(), &state.bar_hitbox); + window.set_cursor_style(CursorStyle::default(), Some(&state.bar_hitbox)); window.paint_layer(hitbox_bounds, |cx| { cx.paint_quad(fill(state.bounds, state.bg)); @@ -682,6 +682,7 @@ impl Element for Scrollbar { } }, border_color: state.border, + border_style: BorderStyle::Solid, }); cx.paint_quad( diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs new file mode 100644 index 0000000..0805289 --- /dev/null +++ b/crates/ui/src/text.rs @@ -0,0 +1,379 @@ +use common::profile::NostrProfile; +use gpui::{ + AnyElement, AnyView, App, ElementId, FontWeight, HighlightStyle, InteractiveText, IntoElement, + SharedString, StyledText, UnderlineStyle, Window, +}; +use linkify::{LinkFinder, LinkKind}; +use nostr_sdk::prelude::*; +use once_cell::sync::Lazy; +use regex::Regex; +use std::{collections::HashMap, ops::Range, sync::Arc}; + +use crate::theme::{scale::ColorScaleStep, ActiveTheme}; + +static NOSTR_URI_REGEX: Lazy = + Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap()); + +static BECH32_REGEX: Lazy = + Lazy::new(|| Regex::new(r"\b(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+\b").unwrap()); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Highlight { + Highlight(HighlightStyle), + Mention, +} + +impl From for Highlight { + fn from(style: HighlightStyle) -> Self { + Self::Highlight(style) + } +} + +type CustomRangeTooltipFn = + Option, &mut Window, &mut App) -> Option>>; + +#[derive(Clone, Default)] +pub struct RichText { + pub text: SharedString, + pub highlights: Vec<(Range, Highlight)>, + pub link_ranges: Vec>, + pub link_urls: Arc<[String]>, + pub custom_ranges: Vec>, + custom_ranges_tooltip_fn: CustomRangeTooltipFn, +} + +impl RichText { + pub fn new(content: String, profiles: &[NostrProfile]) -> Self { + let mut text = String::new(); + let mut highlights = Vec::new(); + let mut link_ranges = Vec::new(); + let mut link_urls = Vec::new(); + + render_plain_text_mut( + &content, + profiles, + &mut text, + &mut highlights, + &mut link_ranges, + &mut link_urls, + ); + + text.truncate(text.trim_end().len()); + + RichText { + text: SharedString::from(text), + link_urls: link_urls.into(), + link_ranges, + highlights, + custom_ranges: Vec::new(), + custom_ranges_tooltip_fn: None, + } + } + + pub fn set_tooltip_builder_for_custom_ranges( + &mut self, + f: impl Fn(usize, Range, &mut Window, &mut App) -> Option + 'static, + ) { + self.custom_ranges_tooltip_fn = Some(Arc::new(f)); + } + + pub fn element(&self, id: ElementId, window: &mut Window, cx: &App) -> AnyElement { + let link_color = cx.theme().accent.step(cx, ColorScaleStep::ELEVEN); + + InteractiveText::new( + id, + StyledText::new(self.text.clone()).with_default_highlights( + &window.text_style(), + self.highlights.iter().map(|(range, highlight)| { + ( + range.clone(), + match highlight { + Highlight::Highlight(highlight) => { + // Check if this is a link highlight by seeing if it has an underline + if highlight.underline.is_some() { + // It's a link, so apply the link color + let mut link_style = *highlight; + link_style.color = Some(link_color); + link_style + } else { + *highlight + } + } + Highlight::Mention => HighlightStyle { + color: Some(link_color), + font_weight: Some(FontWeight::MEDIUM), + ..Default::default() + }, + }, + ) + }), + ), + ) + .on_click(self.link_ranges.clone(), { + let link_urls = self.link_urls.clone(); + move |ix, _, cx| { + let url = &link_urls[ix]; + if url.starts_with("http") { + cx.open_url(url); + } + // Handle mention URLs + else if url.starts_with("mention:") { + // Handle mention clicks + // For example: cx.emit_custom_event(MentionClicked(url.strip_prefix("mention:").unwrap().to_string())); + } + } + }) + .tooltip({ + let link_ranges = self.link_ranges.clone(); + let link_urls = self.link_urls.clone(); + let custom_tooltip_ranges = self.custom_ranges.clone(); + let custom_tooltip_fn = self.custom_ranges_tooltip_fn.clone(); + move |idx, window, cx| { + for (ix, range) in link_ranges.iter().enumerate() { + if range.contains(&idx) { + let url = &link_urls[ix]; + if url.starts_with("http") { + // return Some(LinkPreview::new(url, cx)); + } + // You can add custom tooltip handling for mentions here + } + } + for range in &custom_tooltip_ranges { + if range.contains(&idx) { + if let Some(f) = &custom_tooltip_fn { + return f(idx, range.clone(), window, cx); + } + } + } + None + } + }) + .into_any_element() + } +} + +pub fn render_plain_text_mut( + content: &str, + profiles: &[NostrProfile], + text: &mut String, + highlights: &mut Vec<(Range, Highlight)>, + link_ranges: &mut Vec>, + link_urls: &mut Vec, +) { + // Copy the content directly + text.push_str(content); + + // Create a profile lookup using PublicKey directly + let profile_lookup: HashMap<&PublicKey, &NostrProfile> = profiles + .iter() + .map(|profile| (&profile.public_key, profile)) + .collect(); + + // Process regular URLs using linkify + let mut finder = LinkFinder::new(); + finder.kinds(&[LinkKind::Url]); + + // Collect all URLs + let mut url_matches: Vec<(Range, String)> = Vec::new(); + + for link in finder.links(content) { + let start = link.start(); + let end = link.end(); + let range = start..end; + let url = link.as_str().to_string(); + + url_matches.push((range, url)); + } + + // Process nostr entities with nostr: prefix + let mut nostr_matches: Vec<(Range, String)> = Vec::new(); + + for nostr_match in NOSTR_URI_REGEX.find_iter(content) { + let start = nostr_match.start(); + let end = nostr_match.end(); + let range = start..end; + let nostr_uri = nostr_match.as_str().to_string(); + + // Check if this nostr URI overlaps with any already processed URL + if !url_matches + .iter() + .any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end) + { + nostr_matches.push((range, nostr_uri)); + } + } + + // Process raw bech32 entities (without nostr: prefix) + let mut bech32_matches: Vec<(Range, String)> = Vec::new(); + + for bech32_match in BECH32_REGEX.find_iter(content) { + let start = bech32_match.start(); + let end = bech32_match.end(); + let range = start..end; + let bech32_entity = bech32_match.as_str().to_string(); + + // Check if this entity overlaps with any already processed matches + let overlaps_with_url = url_matches + .iter() + .any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end); + + let overlaps_with_nostr = nostr_matches + .iter() + .any(|(nostr_range, _)| nostr_range.start < range.end && range.start < nostr_range.end); + + if !overlaps_with_url && !overlaps_with_nostr { + bech32_matches.push((range, bech32_entity)); + } + } + + // Combine all matches for processing from end to start + let mut all_matches = Vec::new(); + all_matches.extend(url_matches); + all_matches.extend(nostr_matches); + all_matches.extend(bech32_matches); + + // Sort by position (end to start) to avoid changing positions when replacing text + all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start)); + + // Process all matches + for (range, entity) in all_matches { + if entity.starts_with("http") { + // Regular URL + highlights.push(( + range.clone(), + Highlight::Highlight(HighlightStyle { + underline: Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }), + ..Default::default() + }), + )); + + link_ranges.push(range); + link_urls.push(entity); + } else { + let entity_without_prefix = if entity.starts_with("nostr:") { + entity.strip_prefix("nostr:").unwrap_or(&entity) + } else { + &entity + }; + + // Try to find a matching profile if this is npub or nprofile + let profile_match = if entity_without_prefix.starts_with("npub") { + PublicKey::from_bech32(entity_without_prefix) + .ok() + .and_then(|pubkey| profile_lookup.get(&pubkey).copied()) + } else if entity_without_prefix.starts_with("nprofile") { + Nip19Profile::from_bech32(entity_without_prefix) + .ok() + .and_then(|profile| profile_lookup.get(&profile.public_key).copied()) + } else { + None + }; + + if let Some(profile) = profile_match { + // Profile found - create a mention + let display_name = format!("@{}", profile.name); + + // Replace mention with profile name + text.replace_range(range.clone(), &display_name); + + // Adjust ranges + let new_length = display_name.len(); + let length_diff = new_length as isize - (range.end - range.start) as isize; + + // New range for the replacement + let new_range = range.start..(range.start + new_length); + + // Add highlight for the profile name + highlights.push((new_range.clone(), Highlight::Mention)); + + // Make it clickable + link_ranges.push(new_range); + link_urls.push(format!("mention:{}", entity_without_prefix)); + + // Adjust subsequent ranges if needed + if length_diff != 0 { + adjust_ranges(highlights, link_ranges, range.end, length_diff); + } + } else { + // No profile match or not a profile entity - create njump.me link + let njump_url = format!("https://njump.me/{}", entity_without_prefix); + + // Create a shortened display format for the URL + let shortened_entity = format_shortened_entity(entity_without_prefix); + let display_text = format!("https://njump.me/{}", shortened_entity); + + // Replace the original entity with the shortened display version + text.replace_range(range.clone(), &display_text); + + // Adjust the ranges + let new_length = display_text.len(); + let length_diff = new_length as isize - (range.end - range.start) as isize; + + // New range for the replacement + let new_range = range.start..(range.start + new_length); + + // Add underline highlight + highlights.push(( + new_range.clone(), + Highlight::Highlight(HighlightStyle { + underline: Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }), + ..Default::default() + }), + )); + + // Make it clickable + link_ranges.push(new_range); + link_urls.push(njump_url); + + // Adjust subsequent ranges if needed + if length_diff != 0 { + adjust_ranges(highlights, link_ranges, range.end, length_diff); + } + } + } + } +} + +/// Format a bech32 entity with ellipsis and last 4 characters +fn format_shortened_entity(entity: &str) -> String { + let prefix_end = entity.find('1').unwrap_or(0); + + if prefix_end > 0 && entity.len() > prefix_end + 5 { + let prefix = &entity[0..=prefix_end]; // Include the '1' + let suffix = &entity[entity.len() - 4..]; // Last 4 chars + + format!("{}...{}", prefix, suffix) + } else { + entity.to_string() + } +} + +// Helper function to adjust ranges when text length changes +fn adjust_ranges( + highlights: &mut [(Range, Highlight)], + link_ranges: &mut [Range], + position: usize, + length_diff: isize, +) { + // Adjust highlight ranges + for (range, _) in highlights.iter_mut() { + if range.start > position { + range.start = (range.start as isize + length_diff) as usize; + range.end = (range.end as isize + length_diff) as usize; + } + } + + // Adjust link ranges + for range in link_ranges.iter_mut() { + if range.start > position { + range.start = (range.start as isize + length_diff) as usize; + range.end = (range.end as isize + length_diff) as usize; + } + } +} diff --git a/crates/ui/src/window_border.rs b/crates/ui/src/window_border.rs index 5b734d1..dc7fc5e 100644 --- a/crates/ui/src/window_border.rs +++ b/crates/ui/src/window_border.rs @@ -104,7 +104,7 @@ impl RenderOnce for WindowBorder { CursorStyle::ResizeUpRightDownLeft } }, - &hitbox, + Some(&hitbox), ); }, )