From cfc2300c0c2fc8f9f4400e4f91fd01b79eeb8687 Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Fri, 28 Mar 2025 09:49:07 +0700 Subject: [PATCH] feat: Rich Text Rendering (#13) * add text * fix avatar is not show * refactor chats * improve rich text * add benchmark for text * update --- Cargo.lock | 291 +++++++++----- Cargo.toml | 2 +- assets/brand/avatar.jpg | Bin 5214 -> 0 bytes assets/brand/avatar.png | Bin 0 -> 5899 bytes crates/app/Cargo.toml | 1 - crates/app/src/chat_space.rs | 13 +- crates/app/src/main.rs | 14 +- crates/app/src/views/chat.rs | 480 +++++++++--------------- crates/app/src/views/new_account.rs | 8 +- crates/app/src/views/profile.rs | 8 +- crates/app/src/views/sidebar/mod.rs | 7 +- crates/chats/Cargo.toml | 2 + crates/chats/src/lib.rs | 22 +- crates/chats/src/message.rs | 57 +++ crates/chats/src/room.rs | 211 +++++++++-- crates/common/src/profile.rs | 2 +- crates/ui/Cargo.toml | 11 + crates/ui/benches/text_benchmark.rs | 142 +++++++ crates/ui/src/lib.rs | 1 + crates/ui/src/scroll/scrollable_mask.rs | 7 +- crates/ui/src/scroll/scrollbar.rs | 9 +- crates/ui/src/text.rs | 379 +++++++++++++++++++ crates/ui/src/window_border.rs | 2 +- 23 files changed, 1180 insertions(+), 489 deletions(-) delete mode 100644 assets/brand/avatar.jpg create mode 100644 assets/brand/avatar.png create mode 100644 crates/chats/src/message.rs create mode 100644 crates/ui/benches/text_benchmark.rs create mode 100644 crates/ui/src/text.rs 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 d40b379122c3c404a8329006ef635b8150a2f0be..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5214 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 diff --git a/assets/brand/avatar.png b/assets/brand/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..0acd40d8fa4ef5805aeb40b40191b81c90af55ed GIT binary patch literal 5899 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 literal 0 HcmV?d00001 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), ); }, )