feat: Rich Text Rendering (#13)

* add text

* fix avatar is not show

* refactor chats

* improve rich text

* add benchmark for text

* update
This commit is contained in:
reya
2025-03-28 09:49:07 +07:00
committed by GitHub
parent 42d6328d82
commit cfc2300c0c
23 changed files with 1180 additions and 489 deletions

291
Cargo.lock generated
View File

@@ -97,6 +97,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.18" version = "0.6.18"
@@ -828,6 +834,12 @@ dependencies = [
"wayland-client", "wayland-client",
] ]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]] [[package]]
name = "cbc" name = "cbc"
version = "0.1.2" version = "0.1.2"
@@ -944,10 +956,12 @@ dependencies = [
"gpui", "gpui",
"itertools 0.13.0", "itertools 0.13.0",
"log", "log",
"nostr",
"nostr-sdk", "nostr-sdk",
"oneshot", "oneshot",
"smallvec", "smallvec",
"smol", "smol",
"ui",
] ]
[[package]] [[package]]
@@ -964,6 +978,33 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@@ -988,9 +1029,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.32" version = "4.5.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83" checksum = "e2c80cae4c3350dd8f1272c73e83baff9a6ba550b8bfbe651b3c45b78cd1751e"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@@ -998,9 +1039,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.32" version = "4.5.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8" checksum = "0123e386f691c90aa228219b5b1ee72d465e8e231c79e9c82324f016a62a741c"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@@ -1108,7 +1149,7 @@ dependencies = [
[[package]] [[package]]
name = "collections" name = "collections"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
@@ -1199,7 +1240,6 @@ dependencies = [
"global", "global",
"gpui", "gpui",
"itertools 0.13.0", "itertools 0.13.0",
"keyring",
"log", "log",
"nostr-connect", "nostr-connect",
"nostr-sdk", "nostr-sdk",
@@ -1351,6 +1391,42 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.6" version = "0.8.6"
@@ -1430,35 +1506,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" 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]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.19" version = "0.99.19"
@@ -1475,7 +1522,7 @@ dependencies = [
[[package]] [[package]]
name = "derive_refineable" name = "derive_refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1592,9 +1639,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]] [[package]]
name = "dwrote" name = "dwrote"
version = "0.11.2" version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70182709525a3632b2ba96b6569225467b18ecb4a77f46d255f713a6bebf05fd" checksum = "bfe1f192fcce01590bd8d839aca53ce0d11d803bf291b2a6c4ad925a8f0024be"
dependencies = [ dependencies = [
"lazy_static", "lazy_static",
"libc", "libc",
@@ -2235,7 +2282,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui" name = "gpui"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"as-raw-xcb-connection", "as-raw-xcb-connection",
@@ -2323,7 +2370,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_macros" name = "gpui_macros"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2435,6 +2482,12 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
[[package]]
name = "hermit-abi"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e"
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
@@ -2546,7 +2599,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client" name = "http_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2562,7 +2615,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client_tls" name = "http_client_tls"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019"
dependencies = [ dependencies = [
"rustls", "rustls",
"rustls-platform-verifier", "rustls-platform-verifier",
@@ -2697,9 +2750,9 @@ dependencies = [
[[package]] [[package]]
name = "icu_locid_transform_data" name = "icu_locid_transform_data"
version = "1.5.0" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d"
[[package]] [[package]]
name = "icu_normalizer" name = "icu_normalizer"
@@ -2721,9 +2774,9 @@ dependencies = [
[[package]] [[package]]
name = "icu_normalizer_data" name = "icu_normalizer_data"
version = "1.5.0" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7"
[[package]] [[package]]
name = "icu_properties" name = "icu_properties"
@@ -2742,9 +2795,9 @@ dependencies = [
[[package]] [[package]]
name = "icu_properties_data" name = "icu_properties_data"
version = "1.5.0" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2"
[[package]] [[package]]
name = "icu_provider" name = "icu_provider"
@@ -2908,6 +2961,17 @@ dependencies = [
"once_cell", "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]] [[package]]
name = "is-wsl" name = "is-wsl"
version = "0.4.0" version = "0.4.0"
@@ -2924,6 +2988,15 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.12.1" version = "0.12.1"
@@ -3010,18 +3083,6 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "khronos-egl" name = "khronos-egl"
version = "6.0.0" version = "6.0.0"
@@ -3069,15 +3130,6 @@ version = "0.2.171"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" 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]] [[package]]
name = "libfuzzer-sys" name = "libfuzzer-sys"
version = "0.4.9" version = "0.4.9"
@@ -3114,6 +3166,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linkify"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.15" version = "0.4.15"
@@ -3268,7 +3329,7 @@ dependencies = [
[[package]] [[package]]
name = "media" name = "media"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bindgen 0.70.1", "bindgen 0.70.1",
@@ -3447,7 +3508,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]] [[package]]
name = "nostr" name = "nostr"
version = "0.40.0" version = "0.40.0"
source = "git+https://github.com/rust-nostr/nostr#b513d781b55ec90b367ce58a0d91ead4e92242e7" source = "git+https://github.com/rust-nostr/nostr#07306c9333c08bad5dc633fd97a59d26eb3d4443"
dependencies = [ dependencies = [
"aes", "aes",
"base64", "base64",
@@ -3472,7 +3533,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-connect" name = "nostr-connect"
version = "0.40.0" version = "0.40.0"
source = "git+https://github.com/rust-nostr/nostr#b513d781b55ec90b367ce58a0d91ead4e92242e7" source = "git+https://github.com/rust-nostr/nostr#07306c9333c08bad5dc633fd97a59d26eb3d4443"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"nostr", "nostr",
@@ -3484,7 +3545,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-database" name = "nostr-database"
version = "0.40.0" version = "0.40.0"
source = "git+https://github.com/rust-nostr/nostr#b513d781b55ec90b367ce58a0d91ead4e92242e7" source = "git+https://github.com/rust-nostr/nostr#07306c9333c08bad5dc633fd97a59d26eb3d4443"
dependencies = [ dependencies = [
"flatbuffers", "flatbuffers",
"lru", "lru",
@@ -3495,7 +3556,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-lmdb" name = "nostr-lmdb"
version = "0.40.0" version = "0.40.0"
source = "git+https://github.com/rust-nostr/nostr#b513d781b55ec90b367ce58a0d91ead4e92242e7" source = "git+https://github.com/rust-nostr/nostr#07306c9333c08bad5dc633fd97a59d26eb3d4443"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"heed", "heed",
@@ -3508,7 +3569,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-relay-pool" name = "nostr-relay-pool"
version = "0.40.0" version = "0.40.0"
source = "git+https://github.com/rust-nostr/nostr#b513d781b55ec90b367ce58a0d91ead4e92242e7" source = "git+https://github.com/rust-nostr/nostr#07306c9333c08bad5dc633fd97a59d26eb3d4443"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"async-wsocket", "async-wsocket",
@@ -3525,7 +3586,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-sdk" name = "nostr-sdk"
version = "0.40.0" version = "0.40.0"
source = "git+https://github.com/rust-nostr/nostr#b513d781b55ec90b367ce58a0d91ead4e92242e7" source = "git+https://github.com/rust-nostr/nostr#07306c9333c08bad5dc633fd97a59d26eb3d4443"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"nostr", "nostr",
@@ -3926,6 +3987,12 @@ dependencies = [
"zvariant", "zvariant",
] ]
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]] [[package]]
name = "opaque-debug" name = "opaque-debug"
version = "0.3.1" version = "0.3.1"
@@ -4165,6 +4232,34 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 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]] [[package]]
name = "png" name = "png"
version = "0.17.16" version = "0.17.16"
@@ -4317,9 +4412,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.37.2" version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" checksum = "bf763ab1c7a3aa408be466efc86efe35ed1bd3dd74173ed39d6b0d0a6f0ba148"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -4597,7 +4692,7 @@ dependencies = [
[[package]] [[package]]
name = "refineable" name = "refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019"
dependencies = [ dependencies = [
"derive_refineable", "derive_refineable",
] ]
@@ -4726,7 +4821,7 @@ dependencies = [
[[package]] [[package]]
name = "reqwest_client" name = "reqwest_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -4947,9 +5042,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.0" version = "0.103.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa4eeac2588ffff23e9d7a7e9b3f971c5fb5b7ebc9452745e0c232c64f83b2f" checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"ring", "ring",
@@ -5138,7 +5233,7 @@ checksum = "c2fdfc24bc566f839a2da4c4295b82db7d25a24253867d5c64355abb5799bdbe"
[[package]] [[package]]
name = "semantic_version" name = "semantic_version"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
@@ -5460,7 +5555,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "sum_tree" name = "sum_tree"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"log", "log",
@@ -5830,6 +5925,16 @@ dependencies = [
"zerovec", "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]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.9.0" version = "1.9.0"
@@ -6127,9 +6232,13 @@ version = "0.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"common",
"criterion",
"gpui", "gpui",
"image", "image",
"itertools 0.13.0", "itertools 0.13.0",
"linkify",
"nostr-sdk",
"once_cell", "once_cell",
"paste", "paste",
"regex", "regex",
@@ -6322,7 +6431,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "util" name = "util"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#d9dcc5933449f37143fa38f8c91ee9b30960457b" source = "git+https://github.com/zed-industries/zed#47b94e5ef0efae21daa9799d59f4e133384c8019"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-fs", "async-fs",
@@ -6375,9 +6484,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "value-bag" name = "value-bag"
version = "1.10.0" version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2" checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5"
dependencies = [ dependencies = [
"value-bag-serde1", "value-bag-serde1",
"value-bag-sval2", "value-bag-sval2",
@@ -6385,9 +6494,9 @@ dependencies = [
[[package]] [[package]]
name = "value-bag-serde1" name = "value-bag-serde1"
version = "1.10.0" version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bb773bd36fd59c7ca6e336c94454d9c66386416734817927ac93d81cb3c5b0b" checksum = "35540706617d373b118d550d41f5dfe0b78a0c195dc13c6815e92e2638432306"
dependencies = [ dependencies = [
"erased-serde", "erased-serde",
"serde", "serde",
@@ -6396,9 +6505,9 @@ dependencies = [
[[package]] [[package]]
name = "value-bag-sval2" name = "value-bag-sval2"
version = "1.10.0" version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53a916a702cac43a88694c97657d449775667bcd14b70419441d05b7fea4a83a" checksum = "6fe7e140a2658cc16f7ee7a86e413e803fc8f9b5127adc8755c19f9fefa63a52"
dependencies = [ dependencies = [
"sval", "sval",
"sval_buffer", "sval_buffer",

View File

@@ -15,6 +15,7 @@ gpui = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr # Nostr
nostr = { git = "https://github.com/rust-nostr/nostr", features = ["parser"] }
nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" } nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { 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 = [ nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
@@ -39,7 +40,6 @@ anyhow = "1.0.44"
smallvec = "1.14.0" smallvec = "1.14.0"
rust-embed = "8.5.0" rust-embed = "8.5.0"
log = "0.4" log = "0.4"
keyring = { git = "https://github.com/hwchen/keyring-rs" }
[profile.release] [profile.release]
strip = true strip = true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

BIN
assets/brand/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -30,7 +30,6 @@ log.workspace = true
smallvec.workspace = true smallvec.workspace = true
smol.workspace = true smol.workspace = true
oneshot.workspace = true oneshot.workspace = true
keyring.workspace = true
rustls = "0.23.23" rustls = "0.23.23"
futures = "0.3" futures = "0.3"

View File

@@ -2,8 +2,8 @@ use account::Account;
use global::get_client; use global::get_client;
use gpui::{ use gpui::{
actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis, actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription,
StyledImage, Subscription, Task, Window, Task, Window,
}; };
use serde::Deserialize; use serde::Deserialize;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
@@ -172,14 +172,7 @@ impl ChatSpace {
.icon(Icon::new(IconName::ChevronDownSmall)) .icon(Icon::new(IconName::ChevronDownSmall))
.when_some( .when_some(
Account::global(cx).read(cx).profile.as_ref(), Account::global(cx).read(cx).profile.as_ref(),
|this, profile| { |this, profile| this.child(img(profile.avatar.clone()).size_5()),
this.child(
img(profile.avatar.clone())
.size_5()
.rounded_full()
.object_fit(ObjectFit::Cover),
)
},
) )
.popup_menu(move |this, _, _cx| { .popup_menu(move |this, _, _cx| {
this.menu( this.menu(

View File

@@ -40,6 +40,8 @@ enum Signal {
fn main() { fn main() {
// Enable logging // Enable logging
tracing_subscriber::fmt::init(); 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::<Signal>(1024); let (event_tx, event_rx) = smol::channel::bounded::<Signal>(1024);
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100); let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100);
@@ -55,10 +57,6 @@ fn main() {
// Connect to default relays // Connect to default relays
app.background_executor() app.background_executor()
.spawn(async { .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() { for relay in BOOTSTRAP_RELAYS.into_iter() {
_ = client.add_relay(relay).await; _ = client.add_relay(relay).await;
} }
@@ -223,13 +221,15 @@ fn main() {
let chats = cx.update(|_, cx| ChatRegistry::global(cx)).unwrap(); let chats = cx.update(|_, cx| ChatRegistry::global(cx)).unwrap();
while let Ok(signal) = event_rx.recv().await { while let Ok(signal) = event_rx.recv().await {
cx.update(|_, cx| { cx.update(|window, cx| {
match signal { match signal {
Signal::Eose => { 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) => { Signal::Event(event) => {
chats.update(cx, |this, cx| this.push_message(event, cx)); chats.update(cx, |this, cx| {
this.push_message(event, window, cx)
});
} }
}; };
}) })

View File

@@ -1,89 +1,36 @@
use anyhow::anyhow; use anyhow::{anyhow, Error};
use async_utility::task::spawn; use async_utility::task::spawn;
use chats::{room::Room, ChatRegistry}; use chats::{message::RoomMessage, room::Room, ChatRegistry};
use common::{ use common::utils::nip96_upload;
last_seen::LastSeen,
profile::NostrProfile,
utils::{compare, nip96_upload},
};
use global::{constants::IMAGE_SERVICE, get_client}; use global::{constants::IMAGE_SERVICE, get_client};
use gpui::{ use gpui::{
div, img, list, prelude::FluentBuilder, px, relative, svg, white, AnyElement, App, AppContext, div, img, list, prelude::FluentBuilder, px, relative, svg, white, AnyElement, App, AppContext,
Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement, Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement,
IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, PathPromptOptions, Render, IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, PathPromptOptions, Render,
SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, WeakEntity, SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, Window,
Window,
}; };
use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use smol::fs; use smol::fs;
use std::sync::Arc; use std::{collections::HashMap, sync::Arc};
use ui::{ use ui::{
button::{Button, ButtonRounded, ButtonVariants}, button::{Button, ButtonRounded, ButtonVariants},
dock_area::panel::{Panel, PanelEvent}, dock_area::panel::{Panel, PanelEvent},
input::{InputEvent, TextInput}, input::{InputEvent, TextInput},
notification::Notification, notification::Notification,
popup_menu::PopupMenu, popup_menu::PopupMenu,
text::RichText,
theme::{scale::ColorScaleStep, ActiveTheme}, theme::{scale::ColorScaleStep, ActiveTheme},
v_flex, ContextModal, Icon, IconName, Sizable, StyledExt, 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 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( pub fn init(id: &u64, window: &mut Window, cx: &mut App) -> Result<Arc<Entity<Chat>>, Error> {
id: &u64,
window: &mut Window,
cx: &mut App,
) -> Result<Arc<Entity<Chat>>, anyhow::Error> {
if let Some(room) = ChatRegistry::global(cx).read(cx).get(id, cx) { if let Some(room) = ChatRegistry::global(cx).read(cx).get(id, cx) {
Ok(Arc::new(Chat::new(id, room, window, cx))) Ok(Arc::new(Chat::new(id, room, window, cx)))
} else { } else {
Err(anyhow!("Chat room is not exist")) Err(anyhow!("Chat Room not found."))
}
}
#[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<ParsedMessage>),
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
} }
} }
@@ -92,28 +39,22 @@ pub struct Chat {
id: SharedString, id: SharedString,
focus_handle: FocusHandle, focus_handle: FocusHandle,
// Chat Room // Chat Room
room: WeakEntity<Room>, room: Entity<Room>,
messages: Entity<Vec<Message>>, messages: Entity<Vec<RoomMessage>>,
seens: Entity<Vec<EventId>>, text_data: HashMap<EventId, RichText>,
list_state: ListState, list_state: ListState,
#[allow(dead_code)]
subscriptions: Vec<Subscription>,
// New Message // New Message
input: Entity<TextInput>, input: Entity<TextInput>,
// Media // Media Attachment
attaches: Entity<Option<Vec<Url>>>, attaches: Entity<Option<Vec<Url>>>,
is_uploading: bool, is_uploading: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
} }
impl Chat { impl Chat {
pub fn new( pub fn new(id: &u64, room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Self> {
id: &u64, let messages = cx.new(|_| vec![RoomMessage::announcement()]);
room: WeakEntity<Room>,
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
let messages = cx.new(|_| vec![Message::placeholder()]);
let seens = cx.new(|_| vec![]);
let attaches = cx.new(|_| None); let attaches = cx.new(|_| None);
let input = cx.new(|cx| { let input = cx.new(|cx| {
TextInput::new(window, cx) TextInput::new(window, cx)
@@ -123,7 +64,7 @@ impl Chat {
}); });
cx.new(|cx| { cx.new(|cx| {
let mut subscriptions = Vec::with_capacity(2); let mut subscriptions = smallvec![];
subscriptions.push(cx.subscribe_in( subscriptions.push(cx.subscribe_in(
&input, &input,
@@ -135,15 +76,21 @@ impl Chat {
}, },
)); ));
if let Some(room) = room.upgrade() { subscriptions.push(cx.subscribe_in(
subscriptions.push(cx.subscribe_in( &room,
&room, window,
window, move |this, _, event, _window, cx| {
move |this: &mut Self, _, event, window, cx| { let old_len = this.messages.read(cx).len();
this.push_message(&event.event, window, cx); 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 // Initialize list state
// [item_count] always equal to 1 at the beginning // [item_count] always equal to 1 at the beginning
@@ -161,9 +108,9 @@ impl Chat {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
is_uploading: false, is_uploading: false,
id: id.to_string().into(), id: id.to_string().into(),
text_data: HashMap::new(),
room, room,
messages, messages,
seens,
list_state, list_state,
input, input,
attaches, attaches,
@@ -171,40 +118,37 @@ impl Chat {
}; };
// Verify messaging relays of all members // Verify messaging relays of all members
this.verify_messaging_relays(cx); this.verify_messaging_relays(window, cx);
// Load all messages from database // Load all messages from database
this.load_messages(cx); this.load_messages(window, cx);
this this
}) })
} }
fn verify_messaging_relays(&self, cx: &mut Context<Self>) { fn verify_messaging_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let Some(model) = self.room.upgrade() else { let room = self.room.read(cx);
return; let task = room.messaging_relays(cx);
};
let room = model.read(cx); cx.spawn_in(window, async move |this, cx| {
let task = room.verify_inbox_relays(cx);
cx.spawn(async move |this, cx| {
if let Ok(result) = task.await { if let Ok(result) = task.await {
cx.update(|cx| { cx.update(|_, cx| {
_ = this.update(cx, |this, cx| { this.update(cx, |this, cx| {
result.into_iter().for_each(|item| { result.into_iter().for_each(|item| {
if !item.1 { if !item.1 {
if let Ok(Some(member)) = if let Some(profile) =
this.room.read_with(cx, |this, _| this.member(&item.0)) this.room.read_with(cx, |this, _| this.member(&item.0))
{ {
this.push_system_message( this.push_system_message(
format!("{} {}", member.name, ALERT), format!("{} {}", profile.name, ALERT),
cx, cx,
); );
} }
} }
}); });
}); })
.ok();
}) })
.ok(); .ok();
} }
@@ -212,19 +156,27 @@ impl Chat {
.detach(); .detach();
} }
fn load_messages(&self, cx: &mut Context<Self>) { fn load_messages(&self, window: &mut Window, cx: &mut Context<Self>) {
let Some(model) = self.room.upgrade() else { let room = self.room.read(cx);
return;
};
let room = model.read(cx);
let task = room.load_messages(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 { if let Ok(events) = task.await {
cx.update(|cx| { cx.update(|_, cx| {
this.update(cx, |this, 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(); .ok();
}) })
@@ -236,7 +188,7 @@ impl Chat {
fn push_system_message(&self, content: String, cx: &mut Context<Self>) { fn push_system_message(&self, content: String, cx: &mut Context<Self>) {
let old_len = self.messages.read(cx).len(); 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| { cx.update_entity(&self.messages, |this, cx| {
this.extend(vec![message]); this.extend(vec![message]);
@@ -246,89 +198,7 @@ impl Chat {
self.list_state.splice(old_len..old_len, 1); self.list_state.splice(old_len..old_len, 1);
} }
fn push_message(&mut self, event: &Event, _window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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<Message> = events
.into_iter()
.sorted_by_key(|ev| ev.created_at)
.filter_map(|ev| {
let mut other_pubkeys = ev.tags.public_keys().copied().collect::<Vec<_>>();
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<Self>) { fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(model) = self.room.upgrade() else {
return;
};
// Get message
let mut content = self.input.read(cx).text().to_string(); let mut content = self.input.read(cx).text().to_string();
// Get all attaches and merge its with message // Get all attaches and merge its with message
@@ -353,7 +223,7 @@ impl Chat {
this.set_disabled(true, window, cx); this.set_disabled(true, window, cx);
}); });
let room = model.read(cx); let room = self.room.read(cx);
let task = room.send_message(content, cx); let task = room.send_message(content, cx);
cx.spawn_in(window, async move |this, 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<Self>) { fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let window_handle = window.window_handle();
let paths = cx.prompt_for_paths(PathPromptOptions { let paths = cx.prompt_for_paths(PathPromptOptions {
files: true, files: true,
directories: false, directories: false,
@@ -395,25 +263,24 @@ impl Chat {
// Show loading spinner // Show loading spinner
self.set_loading(true, cx); self.set_loading(true, cx);
// TODO: support multiple upload cx.spawn_in(window, async move |this, cx| {
cx.spawn(async move |this, cx| {
match Flatten::flatten(paths.await.map_err(|e| e.into())) { match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => { Ok(Some(mut paths)) => {
let path = paths.pop().unwrap(); let path = paths.pop().unwrap();
if let Ok(file_data) = fs::read(path).await { if let Ok(file_data) = fs::read(path).await {
let client = get_client();
let (tx, rx) = oneshot::channel::<Url>(); let (tx, rx) = oneshot::channel::<Url>();
spawn(async move { spawn(async move {
let client = get_client();
if let Ok(url) = nip96_upload(client, file_data).await { if let Ok(url) = nip96_upload(client, file_data).await {
_ = tx.send(url); _ = tx.send(url);
} }
}); });
if let Ok(url) = rx.await { if let Ok(url) = rx.await {
_ = cx.update_window(window_handle, |_, _, cx| { cx.update(|_, cx| {
_ = this.update(cx, |this, cx| { this.update(cx, |this, cx| {
// Stop loading spinner // Stop loading spinner
this.set_loading(false, cx); this.set_loading(false, cx);
@@ -425,8 +292,10 @@ impl Chat {
} }
cx.notify(); cx.notify();
}); });
}); })
}); .ok();
})
.ok();
} }
} }
} }
@@ -460,30 +329,32 @@ impl Chat {
cx.notify(); cx.notify();
} }
fn seen(&mut self, id: EventId, cx: &mut Context<Self>) {
self.seens.update(cx, |this, cx| {
this.push(id);
cx.notify();
});
}
fn render_message( fn render_message(
&self, &mut self,
ix: usize, ix: usize,
_window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> impl IntoElement { ) -> impl IntoElement {
if let Some(message) = self.messages.read(cx).get(ix) { const ROOM_DESCRIPTION: &str =
div() "This conversation is private. Only members of this chat can see each other's messages.";
.group("")
.relative() let message = self.messages.read(cx).get(ix).unwrap();
.flex() let text_data = &mut self.text_data;
.gap_3()
.w_full() div()
.p_2() .group("")
.map(|this| match message { .relative()
Message::User(item) => this .flex()
.hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE))) .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( .child(
div() div()
.absolute() .absolute()
@@ -496,12 +367,7 @@ impl Chat {
this.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE)) this.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
}), }),
) )
.child( .child(img(item.author.avatar.clone()).size_8().flex_shrink_0())
img(item.avatar.clone())
.size_8()
.rounded_full()
.flex_shrink_0(),
)
.child( .child(
div() div()
.flex() .flex()
@@ -515,57 +381,60 @@ impl Chat {
.gap_2() .gap_2()
.text_xs() .text_xs()
.child( .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( .child(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN), div()
)), .child(item.created_at.human_readable())
.text_color(
cx.theme()
.base
.step(cx, ColorScaleStep::ELEVEN),
),
),
) )
.child(div().text_sm().child(item.content.clone())), .child(div().text_sm().child(text.element(
), "body".into(),
Message::System(content) => this window,
.items_center() cx,
.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.jpg") RoomMessage::System(content) => this
.size_8() .items_center()
.rounded_full() .child(
.flex_shrink_0(), div()
) .absolute()
.text_xs() .left_0()
.text_color(cx.theme().danger) .top_0()
.child(content.clone()), .w(px(2.))
Message::Placeholder => this .h_full()
.w_full() .bg(cx.theme().transparent)
.h_32() .group_hover("", |this| this.bg(cx.theme().danger)),
.flex() )
.flex_col() .child(img("brand/avatar.png").size_8().flex_shrink_0())
.items_center() .text_xs()
.justify_center() .text_color(cx.theme().danger)
.text_center() .child(content.clone()),
.text_xs() RoomMessage::Announcement => this
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) .w_full()
.line_height(relative(1.2)) .h_32()
.child( .flex()
svg() .flex_col()
.path("brand/coop.svg") .items_center()
.size_8() .justify_center()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)), .text_center()
) .text_xs()
.child(DESCRIPTION), .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
}) .line_height(relative(1.3))
} else { .child(
div() 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 { fn title(&self, cx: &App) -> AnyElement {
self.room self.room.read_with(cx, |this, _| {
.read_with(cx, |this, _| { let facepill: Vec<SharedString> = this
let facepill: Vec<SharedString> = this .members
.members .iter()
.iter() .map(|member| member.avatar.clone())
.map(|member| member.avatar.clone()) .collect();
.collect();
div() div()
.flex() .flex()
.items_center() .items_center()
.gap_1() .gap_1()
.child( .child(
div() div()
.flex() .flex()
.flex_row_reverse() .flex_row_reverse()
.items_center() .items_center()
.justify_start() .justify_start()
.children(facepill.into_iter().enumerate().rev().map(|(ix, face)| { .children(facepill.into_iter().enumerate().rev().map(|(ix, face)| {
div().when(ix > 0, |div| div.ml_neg_1()).child( div()
img(face) .when(ix > 0, |div| div.ml_neg_1())
.size_4() .child(img(face).size_4())
.rounded_full() })),
.object_fit(ObjectFit::Cover), )
) .when_some(this.name(), |this, name| this.child(name))
})), .into_any()
) })
.when_some(this.name(), |this, name| this.child(name))
.into_any()
})
.unwrap_or("Unnamed".into_any())
} }
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu { fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {

View File

@@ -264,12 +264,7 @@ impl Render for NewAccount {
.gap_2() .gap_2()
.map(|this| { .map(|this| {
if self.avatar_input.read(cx).text().is_empty() { if self.avatar_input.read(cx).text().is_empty() {
this.child( this.child(img("brand/avatar.png").size_10().flex_shrink_0())
img("brand/avatar.jpg")
.size_10()
.rounded_full()
.flex_shrink_0(),
)
} else { } else {
this.child( this.child(
img(format!( img(format!(
@@ -278,7 +273,6 @@ impl Render for NewAccount {
self.avatar_input.read(cx).text() self.avatar_input.read(cx).text()
)) ))
.size_10() .size_10()
.rounded_full()
.flex_shrink_0(), .flex_shrink_0(),
) )
} }

View File

@@ -296,12 +296,7 @@ impl Render for Profile {
let picture = self.avatar_input.read(cx).text(); let picture = self.avatar_input.read(cx).text();
if picture.is_empty() { if picture.is_empty() {
this.child( this.child(img("brand/avatar.png").size_10().flex_shrink_0())
img("brand/avatar.jpg")
.size_10()
.rounded_full()
.flex_shrink_0(),
)
} else { } else {
this.child( this.child(
img(format!( img(format!(
@@ -310,7 +305,6 @@ impl Render for Profile {
self.avatar_input.read(cx).text() self.avatar_input.read(cx).text()
)) ))
.size_10() .size_10()
.rounded_full()
.flex_shrink_0(), .flex_shrink_0(),
) )
} }

View File

@@ -117,12 +117,7 @@ impl Sidebar {
this.flex() this.flex()
.items_center() .items_center()
.gap_2() .gap_2()
.child( .child(img(member.avatar.clone()).size_6().flex_shrink_0())
img(member.avatar.clone())
.size_6()
.rounded_full()
.flex_shrink_0(),
)
.child(member.name.clone()) .child(member.name.clone())
}) })
} }

View File

@@ -7,8 +7,10 @@ publish.workspace = true
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
global = { path = "../global" } global = { path = "../global" }
ui = { path = "../ui" }
gpui.workspace = true gpui.workspace = true
nostr.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
anyhow.workspace = true anyhow.workspace = true
itertools.workspace = true itertools.workspace = true

View File

@@ -3,12 +3,13 @@ use std::cmp::Reverse;
use anyhow::anyhow; use anyhow::anyhow;
use common::{last_seen::LastSeen, utils::room_hash}; use common::{last_seen::LastSeen, utils::room_hash};
use global::get_client; 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 itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use crate::room::{IncomingEvent, Room}; use crate::room::Room;
pub mod message;
pub mod room; pub mod room;
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
@@ -44,7 +45,7 @@ impl ChatRegistry {
self.rooms.iter().map(|room| room.read(cx).id).collect() self.rooms.iter().map(|room| room.read(cx).id).collect()
} }
pub fn load_chat_rooms(&mut self, cx: &mut Context<Self>) { pub fn load_chat_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let client = get_client(); let client = get_client();
let task: Task<Result<Vec<Event>, Error>> = cx.background_spawn(async move { let task: Task<Result<Vec<Event>, Error>> = cx.background_spawn(async move {
@@ -73,9 +74,9 @@ impl ChatRegistry {
Ok(result) Ok(result)
}); });
cx.spawn(async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
if let Ok(events) = task.await { if let Ok(events) = task.await {
cx.update(|cx| { cx.update(|_, cx| {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
if !events.is_empty() { if !events.is_empty() {
let current_ids = this.current_rooms_ids(cx); let current_ids = this.current_rooms_ids(cx);
@@ -118,11 +119,11 @@ impl ChatRegistry {
self.is_loading self.is_loading
} }
pub fn get(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> { pub fn get(&self, id: &u64, cx: &App) -> Option<Entity<Room>> {
self.rooms self.rooms
.iter() .iter()
.find(|model| model.read(cx).id == *id) .find(|model| model.read(cx).id == *id)
.map(|room| room.downgrade()) .cloned()
} }
pub fn push_room( pub fn push_room(
@@ -144,14 +145,13 @@ impl ChatRegistry {
} }
} }
pub fn push_message(&mut self, event: Event, cx: &mut Context<Self>) { pub fn push_message(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
let id = room_hash(&event); let id = room_hash(&event);
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) { if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
room.update(cx, |this, cx| { room.update(cx, |this, cx| {
this.last_seen = LastSeen(event.created_at); this.set_last_seen(LastSeen(event.created_at), cx);
cx.emit(IncomingEvent { event }); this.emit_message(event, window, cx);
cx.notify();
}); });
// Re-sort rooms by last seen // Re-sort rooms by last seen

View File

@@ -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<NostrProfile>,
pub created_at: LastSeen,
}
impl Message {
pub fn new(
id: EventId,
content: String,
author: NostrProfile,
mentions: Vec<NostrProfile>,
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<Message>),
/// 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
}
}

View File

@@ -1,15 +1,22 @@
use std::collections::HashSet; use std::{collections::HashSet, sync::Arc};
use anyhow::Error; 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 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 nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use crate::message::{Message, RoomMessage};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct IncomingEvent { pub struct IncomingEvent {
pub event: Event, pub event: RoomMessage,
} }
pub struct Room { pub struct Room {
@@ -18,7 +25,7 @@ pub struct Room {
/// Subject of the room /// Subject of the room
pub name: Option<SharedString>, pub name: Option<SharedString>,
/// All members of the room /// All members of the room
pub members: SmallVec<[NostrProfile; 2]>, pub members: Arc<SmallVec<[NostrProfile; 2]>>,
} }
impl EventEmitter<IncomingEvent> for Room {} impl EventEmitter<IncomingEvent> for Room {}
@@ -44,18 +51,19 @@ impl Room {
// Create a task for loading metadata // Create a task for loading metadata
let load_metadata = Self::load_metadata(event, cx); let load_metadata = Self::load_metadata(event, cx);
// Create a new GPUI's Entity
cx.new(|cx| { cx.new(|cx| {
let this = Self { let this = Self {
id, id,
last_seen, last_seen,
name, name,
members: smallvec![], members: Arc::new(smallvec![]),
}; };
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
if let Ok(profiles) = load_metadata.await { if let Ok(profiles) = load_metadata.await {
_ = cx.update(|cx| { cx.update(|cx| {
_ = this.update(cx, |this: &mut Room, cx| { this.update(cx, |this: &mut Room, cx| {
// Update the room's name if it's not already set // Update the room's name if it's not already set
if this.name.is_none() { if this.name.is_none() {
let mut name = profiles let mut name = profiles
@@ -71,12 +79,16 @@ impl Room {
this.name = Some(name.into()) 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(); cx.notify();
}); })
}); .ok();
})
.ok();
} }
}) })
.detach(); .detach();
@@ -122,13 +134,19 @@ impl Room {
self.last_seen self.last_seen
} }
/// Set room's last seen
pub fn set_last_seen(&mut self, last_seen: LastSeen, cx: &mut Context<Self>) {
self.last_seen = last_seen;
cx.notify();
}
/// Get room's last seen as ago format /// Get room's last seen as ago format
pub fn ago(&self) -> SharedString { pub fn ago(&self) -> SharedString {
self.last_seen.ago() self.last_seen.ago()
} }
/// Sync inbox relays for all room's members /// Verify messaging_relays for all room's members
pub fn verify_inbox_relays(&self, cx: &App) -> Task<Result<Vec<(PublicKey, bool)>, Error>> { pub fn messaging_relays(&self, cx: &App) -> Task<Result<Vec<(PublicKey, bool)>, Error>> {
let client = get_client(); let client = get_client();
let pubkeys = self.public_keys(); let pubkeys = self.public_keys();
@@ -157,8 +175,6 @@ impl Room {
} }
/// Send message to all room's members /// 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<Result<Vec<String>, Error>> { pub fn send_message(&self, content: String, cx: &App) -> Task<Result<Vec<String>, Error>> {
let client = get_client(); let client = get_client();
let pubkeys = self.public_keys(); let pubkeys = self.public_keys();
@@ -192,21 +208,149 @@ impl Room {
}) })
} }
/// Load metadata for all members /// Load room messages
pub fn load_messages(&self, cx: &App) -> Task<Result<Events, Error>> { pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<RoomMessage>, Error>> {
let client = get_client(); let client = get_client();
let pubkeys = self.public_keys(); let pubkeys = self.public_keys();
let members = Arc::clone(&self.members);
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::PrivateDirectMessage) .kind(Kind::PrivateDirectMessage)
.authors(pubkeys.iter().copied()) .authors(pubkeys.clone())
.pubkeys(pubkeys); .pubkeys(pubkeys.clone());
cx.background_spawn(async move { cx.background_spawn(async move {
let query = client.database().query(filter).await?; let mut messages = vec![];
Ok(query) 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::<Vec<_>>();
other_pubkeys.push(ev.pubkey);
// Check if the event is from a member of the room
compare(&other_pubkeys, &pubkeys)
})
.collect::<Vec<_>>();
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::<Vec<_>>();
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<Self>) {
let client = get_client();
let members = Arc::clone(&self.members);
let task: Task<Result<RoomMessage, Error>> = 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::<Vec<_>>();
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 /// Load metadata for all members
fn load_metadata(event: &Event, cx: &App) -> Task<Result<Vec<NostrProfile>, Error>> { fn load_metadata(event: &Event, cx: &App) -> Task<Result<Vec<NostrProfile>, Error>> {
let client = get_client(); let client = get_client();
@@ -219,18 +363,23 @@ impl Room {
cx.background_spawn(async move { cx.background_spawn(async move {
let signer = client.signer().await?; let signer = client.signer().await?;
let signer_pubkey = signer.get_public_key().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() { for public_key in pubkeys.into_iter() {
if let Ok(result) = client.database().metadata(public_key).await { let metadata = client
let metadata = result.unwrap_or_default(); .database()
let profile = NostrProfile::new(public_key, metadata); .metadata(public_key)
.await?
.unwrap_or_default();
if public_key == signer_pubkey { // Convert metadata to profile
profiles.push(profile); let profile = NostrProfile::new(public_key, metadata);
} else {
profiles.insert(0, profile); if public_key == signer_pubkey {
} // Room's owner always push to the end of the vector
profiles.push(profile);
} else {
profiles.insert(0, profile);
} }
} }

View File

@@ -33,7 +33,7 @@ impl NostrProfile {
) )
.into() .into()
}) })
.unwrap_or_else(|| "brand/avatar.jpg".into()) .unwrap_or_else(|| "brand/avatar.png".into())
} }
fn extract_name(public_key: &PublicKey, metadata: &Metadata) -> SharedString { fn extract_name(public_key: &PublicKey, metadata: &Metadata) -> SharedString {

View File

@@ -5,6 +5,9 @@ edition.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
common = { path = "../common" }
nostr-sdk.workspace = true
gpui.workspace = true gpui.workspace = true
smol.workspace = true smol.workspace = true
serde.workspace = true serde.workspace = true
@@ -20,3 +23,11 @@ unicode-segmentation = "1.12.0"
uuid = "1.10" uuid = "1.10"
once_cell = "1.19.0" once_cell = "1.19.0"
image = "0.25.1" image = "0.25.1"
linkify = "0.10.0"
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "text_benchmark"
harness = false

View File

@@ -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<NostrProfile> {
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);

View File

@@ -22,6 +22,7 @@ pub mod scroll;
pub mod skeleton; pub mod skeleton;
pub mod switch; pub mod switch;
pub mod tab; pub mod tab;
pub mod text;
pub mod theme; pub mod theme;
pub mod tooltip; pub mod tooltip;

View File

@@ -1,7 +1,7 @@
use gpui::{ use gpui::{
px, relative, App, Axis, Bounds, ContentMask, Corners, Edges, Element, ElementId, EntityId, px, relative, App, Axis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId,
GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, Point, EntityId, GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels,
Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window,
}; };
use crate::AxisExt; use crate::AxisExt;
@@ -111,6 +111,7 @@ impl Element for ScrollableMask {
bounds, bounds,
border_widths: Edges::all(px(1.0)), border_widths: Edges::all(px(1.0)),
border_color: color, border_color: color,
border_style: BorderStyle::Solid,
background: gpui::transparent_white().into(), background: gpui::transparent_white().into(),
corner_radii: Corners::all(px(0.)), corner_radii: Corners::all(px(0.)),
}); });

View File

@@ -1,7 +1,7 @@
use gpui::{ use gpui::{
fill, point, px, relative, App, Bounds, ContentMask, CursorStyle, Edges, Element, EntityId, fill, point, px, relative, App, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element,
Hitbox, Hsla, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, EntityId, Hitbox, Hsla, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
Point, Position, ScrollHandle, ScrollWheelEvent, UniformListScrollHandle, Window, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, UniformListScrollHandle, Window,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
@@ -657,7 +657,7 @@ impl Element for Scrollbar {
let margin_end = state.margin_end; let margin_end = state.margin_end;
let is_vertical = axis.is_vertical(); 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| { window.paint_layer(hitbox_bounds, |cx| {
cx.paint_quad(fill(state.bounds, state.bg)); cx.paint_quad(fill(state.bounds, state.bg));
@@ -682,6 +682,7 @@ impl Element for Scrollbar {
} }
}, },
border_color: state.border, border_color: state.border,
border_style: BorderStyle::Solid,
}); });
cx.paint_quad( cx.paint_quad(

379
crates/ui/src/text.rs Normal file
View File

@@ -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<Regex> =
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
static BECH32_REGEX: Lazy<Regex> =
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<HighlightStyle> for Highlight {
fn from(style: HighlightStyle) -> Self {
Self::Highlight(style)
}
}
type CustomRangeTooltipFn =
Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>;
#[derive(Clone, Default)]
pub struct RichText {
pub text: SharedString,
pub highlights: Vec<(Range<usize>, Highlight)>,
pub link_ranges: Vec<Range<usize>>,
pub link_urls: Arc<[String]>,
pub custom_ranges: Vec<Range<usize>>,
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<usize>, &mut Window, &mut App) -> Option<AnyView> + '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<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
) {
// 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<usize>, 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<usize>, 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<usize>, 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<usize>, Highlight)],
link_ranges: &mut [Range<usize>],
position: usize,
length_diff: isize,
) {
// Adjust highlight ranges
for (range, _) in highlights.iter_mut() {
if range.start > position {
range.start = (range.start as isize + length_diff) as usize;
range.end = (range.end as isize + length_diff) as usize;
}
}
// 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;
}
}
}

View File

@@ -104,7 +104,7 @@ impl RenderOnce for WindowBorder {
CursorStyle::ResizeUpRightDownLeft CursorStyle::ResizeUpRightDownLeft
} }
}, },
&hitbox, Some(&hitbox),
); );
}, },
) )