diff --git a/hyperdrive/packages-build-parameters.json b/hyperdrive/packages-build-parameters.json index f19ec3ddf..b33255ce2 100644 --- a/hyperdrive/packages-build-parameters.json +++ b/hyperdrive/packages-build-parameters.json @@ -10,6 +10,12 @@ "caller-utils" ] }, + "homepage": { + "is_hyperapp": true, + "features": [ + "caller-utils" + ] + }, "spider": { "is_hyperapp": true, "local_dependencies": [ diff --git a/hyperdrive/packages/homepage/Cargo.lock b/hyperdrive/packages/homepage/Cargo.lock index 54df28823..ad957b4ba 100644 --- a/hyperdrive/packages/homepage/Cargo.lock +++ b/hyperdrive/packages/homepage/Cargo.lock @@ -24,7 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.2.15", "once_cell", "version_check", "zerocopy", @@ -527,7 +527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "538a04a37221469cac0ce231b737fd174de2fdfcdd843bdd068cb39ed3e066ad" dependencies = [ "alloy-json-rpc", - "base64", + "base64 0.22.1", "futures-util", "futures-utils-wasm", "serde", @@ -577,6 +577,15 @@ version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +[[package]] +name = "arc-swap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] + [[package]] name = "ark-ff" version = "0.3.0" @@ -743,6 +752,24 @@ dependencies = [ "syn 2.0.91", ] +[[package]] +name = "atomic_refcell" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c" + +[[package]] +name = "auditable-serde" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7bf8143dfc3c0258df908843e169b5cc5fcf76c7718bd66135ef4a9cd558c5" +dependencies = [ + "semver 1.0.24", + "serde", + "serde_json", + "topological-sort", +] + [[package]] name = "auto_impl" version = "1.2.0" @@ -781,6 +808,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -913,6 +946,54 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chat" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.21.7", + "flate2", + "futures", + "homepage_caller_utils", + "hyperapp_macro", + "hyperware-crdt", + "hyperware-pubsub-core", + "hyperware_process_lib", + "process_macros", + "rand", + "rmp-serde", + "serde", + "serde_json", + "wit-bindgen 0.42.1", +] + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "const-hex" version = "1.14.0" @@ -957,6 +1038,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1140,11 +1230,24 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +dependencies = [ + "getrandom 0.2.15", +] [[package]] name = "fastrlp" @@ -1190,6 +1293,16 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1345,8 +1458,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", ] [[package]] @@ -1441,7 +1568,23 @@ dependencies = [ "hyperware_process_lib", "serde", "serde_json", - "wit-bindgen", + "wit-bindgen 0.42.1", +] + +[[package]] +name = "homepage_caller_utils" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures", + "futures-util", + "hyperware_process_lib", + "once_cell", + "process_macros", + "serde", + "serde_json", + "uuid", + "wit-bindgen 0.41.0", ] [[package]] @@ -1538,20 +1681,62 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperapp_macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061414cb1a535e9e2a9c98ac112c1ac770552ae53d6f6309133cdccf5c251659" +dependencies = [ + "hyperware_process_lib", + "proc-macro2", + "quote", + "syn 2.0.91", +] + +[[package]] +name = "hyperware-crdt" +version = "0.1.0" +source = "git+https://github.com/hyperware-ai/hyperware-crdt?rev=a0aefa9#a0aefa95d7c55b7f848622119b269131e16efb71" +dependencies = [ + "base64 0.22.1", + "hyperware_process_lib", + "rmp-serde", + "serde", + "serde_json", + "thiserror 1.0.69", + "yrs", +] + +[[package]] +name = "hyperware-pubsub-core" +version = "0.1.0" +source = "git+https://github.com/hyperware-ai/hyperware-pubsub?rev=abd30a6#abd30a63db2672ce8acb40095a769c2c1cd8d1f5" +dependencies = [ + "async-trait", + "bytes", + "futures-core", + "serde", + "thiserror 1.0.69", + "tracing", + "uuid", +] + [[package]] name = "hyperware_process_lib" -version = "2.1.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3abd008d22c3b96ee43300c4c8dffbf1d072a680a13635b5f9da11a0ce9395" +checksum = "c380fcf57586e4373553e909c93191d7ca8c10e1af7f7af58c83b7b9efbd4703" dependencies = [ "alloy", "alloy-primitives", "alloy-sol-macro", "alloy-sol-types", "anyhow", - "base64", + "base64 0.22.1", "bincode", - "hex", + "color-eyre", + "futures-channel", + "futures-util", "http", "mime_guess", "rand", @@ -1559,10 +1744,13 @@ dependencies = [ "rmp-serde", "serde", "serde_json", - "sha3", "thiserror 1.0.69", + "tracing", + "tracing-error", + "tracing-subscriber", "url", - "wit-bindgen", + "uuid", + "wit-bindgen 0.42.1", ] [[package]] @@ -1730,6 +1918,12 @@ dependencies = [ "syn 2.0.91", ] +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "2.7.0" @@ -1773,9 +1967,9 @@ checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "js-sys" -version = "0.3.76" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -1874,6 +2068,15 @@ dependencies = [ "hashbrown 0.15.2", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1898,11 +2101,12 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.8.2" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1933,6 +2137,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2064,6 +2277,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + [[package]] name = "parity-scale-codec" version = "3.6.12" @@ -2254,6 +2473,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecfcd7b51a1b9249fb47359a9f8d57a9e9dbc71857c5cfd08f98764f7106a3d" +dependencies = [ + "quote", + "syn 2.0.91", +] + [[package]] name = "proptest" version = "1.5.0" @@ -2289,6 +2518,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "radium" version = "0.7.0" @@ -2323,7 +2558,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", ] [[package]] @@ -2379,7 +2614,7 @@ version = "0.12.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -2651,6 +2886,9 @@ name = "semver" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" +dependencies = [ + "serde", +] [[package]] name = "semver-parser" @@ -2663,18 +2901,28 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.216" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.216" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2736,6 +2984,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2752,6 +3009,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.9" @@ -2761,6 +3024,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "smallstr" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862077b1e764f04c251fe82a2ef562fd78d7cadaeb072ca7c2bcaf7217b1ff3b" +dependencies = [ + "smallvec", +] + [[package]] name = "smallvec" version = "1.13.2" @@ -2780,6 +3052,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spdx" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b69356da67e2fc1f542c71ea7e654a361a79c938e4424392ecf4fa065d2193" +dependencies = [ + "smallvec", +] + [[package]] name = "spki" version = "0.7.3" @@ -2943,6 +3224,15 @@ dependencies = [ "syn 2.0.91", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "threadpool" version = "1.8.1" @@ -3050,6 +3340,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "topological-sort" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" + [[package]] name = "tower" version = "0.5.2" @@ -3105,6 +3401,59 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", ] [[package]] @@ -3184,6 +3533,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.0" @@ -3227,35 +3588,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "wasm-bindgen" -version = "0.2.99" +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "cfg-if", - "once_cell", - "wasm-bindgen-macro", + "wit-bindgen 0.46.0", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.99" +name = "wasm-bindgen" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.91", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.49" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", @@ -3266,9 +3624,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.99" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3276,22 +3634,35 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.99" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn 2.0.91", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.99" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822" +dependencies = [ + "leb128fmt", + "wasmparser 0.227.1", +] [[package]] name = "wasm-encoder" @@ -3300,7 +3671,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4349d0943718e6e434b51b9639e876293093dca4b96384fb136ab5bd5ce6660" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.230.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1ef0faabbbba6674e97a56bee857ccddf942785a336c8b47b42373c922a91d" +dependencies = [ + "anyhow", + "auditable-serde", + "flate2", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "url", + "wasm-encoder 0.227.1", + "wasmparser 0.227.1", ] [[package]] @@ -3311,8 +3701,20 @@ checksum = "1a52e010df5494f4289ccc68ce0c2a8c17555225a5e55cc41b98f5ea28d0844b" dependencies = [ "anyhow", "indexmap", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.230.0", + "wasmparser 0.230.0", +] + +[[package]] +name = "wasmparser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" +dependencies = [ + "bitflags", + "hashbrown 0.15.2", + "indexmap", + "semver 1.0.24", ] [[package]] @@ -3343,9 +3745,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.76" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -3472,14 +3874,41 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de" +dependencies = [ + "wit-bindgen-rt 0.41.0", + "wit-bindgen-rust-macro 0.41.0", +] + [[package]] name = "wit-bindgen" version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa5b79cd8cb4b27a9be3619090c03cbb87fe7b1c6de254b4c9b4477188828af8" dependencies = [ - "wit-bindgen-rt", - "wit-bindgen-rust-macro", + "wit-bindgen-rt 0.42.1", + "wit-bindgen-rust-macro 0.42.1", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "wit-bindgen-core" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.227.1", ] [[package]] @@ -3490,7 +3919,18 @@ checksum = "e35e550f614e16db196e051d22b0d4c94dd6f52c90cb1016240f71b9db332631" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.230.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" +dependencies = [ + "bitflags", + "futures", + "once_cell", ] [[package]] @@ -3504,6 +3944,22 @@ dependencies = [ "once_cell", ] +[[package]] +name = "wit-bindgen-rust" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.91", + "wasm-metadata 0.227.1", + "wit-bindgen-core 0.41.0", + "wit-component 0.227.1", +] + [[package]] name = "wit-bindgen-rust" version = "0.42.1" @@ -3515,9 +3971,24 @@ dependencies = [ "indexmap", "prettyplease", "syn 2.0.91", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "wasm-metadata 0.230.0", + "wit-bindgen-core 0.42.1", + "wit-component 0.230.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad19eec017904e04c60719592a803ee5da76cb51c81e3f6fbf9457f59db49799" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.91", + "wit-bindgen-core 0.41.0", + "wit-bindgen-rust 0.41.0", ] [[package]] @@ -3531,8 +4002,27 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.91", - "wit-bindgen-core", - "wit-bindgen-rust", + "wit-bindgen-core 0.42.1", + "wit-bindgen-rust 0.42.1", +] + +[[package]] +name = "wit-component" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.227.1", + "wasm-metadata 0.227.1", + "wasmparser 0.227.1", + "wit-parser 0.227.1", ] [[package]] @@ -3548,10 +4038,28 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "wasm-encoder 0.230.0", + "wasm-metadata 0.230.0", + "wasmparser 0.230.0", + "wit-parser 0.230.0", +] + +[[package]] +name = "wit-parser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver 1.0.24", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.227.1", ] [[package]] @@ -3569,7 +4077,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.230.0", ] [[package]] @@ -3617,6 +4125,22 @@ dependencies = [ "synstructure", ] +[[package]] +name = "yrs" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8ca5126331b9a5ef5bb10f3f1c3d01b05f298d348c66f8fb15497d83ee73176" +dependencies = [ + "arc-swap", + "atomic_refcell", + "fastrand", + "serde", + "serde_json", + "smallstr", + "smallvec", + "thiserror 1.0.69", +] + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/hyperdrive/packages/homepage/Cargo.toml b/hyperdrive/packages/homepage/Cargo.toml index 3971ed096..8c2e58158 100644 --- a/hyperdrive/packages/homepage/Cargo.toml +++ b/hyperdrive/packages/homepage/Cargo.toml @@ -1,10 +1,12 @@ +[profile.release] +lto = true +opt-level = "s" +panic = "abort" + [workspace] -resolver = "2" members = [ "homepage", + "chat", + "target/homepage-caller-util?", ] - -[profile.release] -panic = "abort" -opt-level = "s" -lto = true +resolver = "2" diff --git a/hyperdrive/packages/homepage/api/chat-ware-dot-hypr-v0.wit b/hyperdrive/packages/homepage/api/chat-ware-dot-hypr-v0.wit new file mode 100644 index 000000000..f24ffc5fd --- /dev/null +++ b/hyperdrive/packages/homepage/api/chat-ware-dot-hypr-v0.wit @@ -0,0 +1,4 @@ +world chat-ware-dot-hypr-v0 { + import chat; + include process-v1; +} \ No newline at end of file diff --git a/hyperdrive/packages/homepage/api/chat.wit b/hyperdrive/packages/homepage/api/chat.wit new file mode 100644 index 000000000..2a6671dbd --- /dev/null +++ b/hyperdrive/packages/homepage/api/chat.wit @@ -0,0 +1,1533 @@ +interface chat { +use standard.{address}; + + // Arbitrary JSON value; encoded as string for WIT 1.0 (TS: unknown, Rust: serde_json::Value) + type value = string; + + record add-group-reaction-req { + group-id: group-id, + message-id: message-id, + emoji: string + } + + record add-reaction-req { + chat-id: string, + message-id: string, + emoji: string + } + + record admin-replication-state-req { + group-id: option + } + + record admin-replication-state-res { + metrics: replication-metrics, + groups: list + } + + record admin-whitelist-req { + group-id: group-id + } + + record admin-whitelist-res { + group-id: group-id, + version: u64, + entries: list + } + + record approve-group-membership-req { + group-id: group-id, + proposal-id: string + } + + record crdt-apply-res { + applied: bool + } + + record crdt-group-apply-req { + group-id: group-id, + update-payload: string, + acl-version: option + } + + record crdt-group-snapshot-req { + group-id: group-id + } + + record crdt-group-state-vector-req { + group-id: group-id + } + + record crdt-group-update-req { + group-id: group-id, + state-vector: option + } + + record crdt-state-vector-res { + state-vector: string + } + + record crdt-update-res { + doc-id: string, + update-payload: string + } + + record create-chat-link-req { + chat-id: string, + single-use: bool + } + + record create-chat-req { + counterparty: string + } + + record create-group-join-link-req { + group-id: group-id + } + + record create-group-join-link-res { + link: string + } + + record create-group-req { + group-id: option, + name: string, + description: option, + avatar: option, + visibility: option, + default-role-label: option, + membership-rules: list, + root-thread-title: option + } + + record create-group-res { + group-id: group-id + } + + record create-group-thread-req { + group-id: group-id, + parent-thread-id: option, + title: option, + root-message-id: option + } + + record create-group-thread-res { + thread-id: thread-id + } + + record delete-chat-req { + chat-id: string + } + + record delete-group-message-req { + group-id: group-id, + message-id: message-id, + delete-for-both: option + } + + record delete-message-req { + chat-id: string, + message-id: string, + delete-for-both: option + } + + record download-file-req { + chat-id: string, + file-id: string + } + + record download-group-file-req { + group-id: group-id, + attachment-id: string + } + + record edit-group-message-req { + group-id: group-id, + message-id: message-id, + new-content: string + } + + record edit-message-req { + chat-id: string, + message-id: string, + new-content: string + } + + record fetch-group-file-req { + group-id: group-id, + attachment-id: string + } + + record forward-message-req { + from-chat-id: string, + message-id: string, + to-chat-id: string + } + + record get-chat-req { + chat-id: string + } + + record get-group-req { + group-id: group-id + } + + record get-group-res { + group: option + } + + record get-messages-req { + chat-id: string, + before-timestamp: option, + limit: option + } + + record get-sync-hash-req { + chat-id: string + } + + record group-replication-state { + group-id: group-id, + pending-bootstrap: bool, + routing: group-routing-config, + hubs: list, + subscribers: list, + hub-cursors: list>, + subscriber-cursors: list>, + whitelist-version: option, + subscriber-lag-secs: option, + hub-lag-secs: option + } + + record invite-group-member-req { + group-id: group-id, + candidate: node-id, + role-id: string + } + + record join-group-link-remote-req { + key: string + } + + record join-group-link-req { + host: node-id, + key: string + } + + record join-group-link-res { + group-id: group-id + } + + record list-groups-res { + groups: list + } + + record membership-decision-res { + decision: membership-decision + } + + record push-snapshot-to-peer-req { + group-id: group-id, + peer: string + } + + record remove-group-member-req { + group-id: group-id, + member: node-id + } + + record remove-group-reaction-req { + group-id: group-id, + message-id: message-id, + emoji: string + } + + record remove-reaction-req { + chat-id: string, + message-id: string, + emoji: string + } + + record replication-metrics { + acl-skips: u64, + retries: u64, + drops: u64, + stale-replays: u64, + last-lag-secs: u64, + last-subscriber-lag-secs: u64, + acl-drifts: u64 + } + + record revoke-chat-key-req { + key: string + } + + record chat-key { + key: string, + user-name: string, + created-at: u64, + is-revoked: bool, + chat-id: string + } + + record search-chats-req { + query: string + } + + record search-index-req { + query: string, + scope: search-scope, + limit: option + } + + record search-index-res { + results: list + } + + record search-result-item { + kind: search-result-kind, + chat-id: option, + group-id: option, + message-id: option, + thread-id: option, + title: string, + snippet: option, + timestamp: option + } + + enum search-result-kind { + chat-summary, + chat-message, + group-summary, + group-message + } + + record group-summary { + group-id: group-id, + metadata: option, + member-count: u64, + thread-count: u64, + unread-count: u32, + notify: bool + } + + enum search-scope { + chats, + groups, + messages, + all + } + + record send-group-message-req { + group-id: group-id, + thread-id: option, + content: string, + message-type: message-type, + reply-to: option, + attachments: list + } + + record send-group-message-res { + message: message-meta + } + + record send-group-voice-note-req { + group-id: group-id, + thread-id: option, + reply-to: option, + audio-data: string, + duration: u32, + mime-type: string + } + + record send-message-req { + chat-id: string, + content: string, + reply-to: option, + file-info: option + } + + record send-voice-note-req { + chat-id: string, + audio-data: string, + duration: u32, + reply-to: option + } + + record spider-connect-result { + api-key: string + } + + record spider-history { + messages: list + } + + record spider-set-history-req { + messages: list + } + + record spider-status-info { + connected: bool, + has-api-key: bool, + spider-available: bool + } + + record subscriber-events-req { + take: option, + clear: bool + } + + record subscriber-events-res { + events: list + } + + record subscriber-delivery-event { + group-id: group-id, + topic: string, + offset: u64, + kind: replication-kind, + age-secs: u64, + recorded-at: u64 + } + + enum replication-kind { + push-delta, + push-snapshot, + pull-snapshot, + pull-delta + } + + record sync-hash-info { + chat-id: string, + message-count: u32, + last-message-id: option, + last-message-timestamp: option, + hash: string + } + + record update-chat-settings-req { + chat-id: string, + notify: option + } + + record update-group-settings-req { + group-id: group-id, + notify: option + } + + record settings { + show-images: bool, + show-profile-pics: bool, + combine-chats-groups: bool, + notify-chats: bool, + notify-groups: bool, + notify-calls: bool, + allow-browser-chats: bool, + stt-enabled: bool, + stt-api-key: option, + max-file-size-mb: u64 + } + + record upload-file-req { + chat-id: string, + filename: string, + mime-type: string, + data: string, + reply-to: option + } + + record upload-group-file-req { + group-id: group-id, + thread-id: option, + reply-to: option, + filename: string, + mime-type: string, + data: string + } + + record upload-profile-picture-req { + mime-type: string, + data: string + } + + record whitelist-entry-debug { + node: string, + publish: list, + subscribe: list, + audiences: list, + features: list, + expires-at: option + } + + record attachment-descriptor { + attachment-id: string, + filename: string, + mime-type: string, + size-bytes: u64, + checksum: option, + uri: option + } + + record chat { + id: string, + counterparty: string, + messages: list, + last-activity: u64, + unread-count: u32, + is-blocked: bool, + notify: bool, + counterparty-profile: option + } + + record chat-message { + id: string, + sender: string, + content: string, + timestamp: u64, + sequence: option, + status: message-status, + reply-to: option, + reactions: list, + message-type: message-type, + file-info: option + } + + record delivery-cursor { + queue-id: string, + last-offset: u64, + updated-at: u64 + } + + record file-info { + filename: string, + mime-type: string, + size: u64, + url: string + } + + record group { + metadata: option, + roles: list>, + members: list>, + hubs: group-hub-set, + subscribers: group-subscriber-set, + routing: group-routing-config, + delivery: group-delivery-state, + membership-rules: list, + membership-proposals: list>, + threads: list>, + messages: list>, + counters: group-counters + } + + record group-counters { + next-thread: u64, + next-message: u64 + } + + record group-delivery-state { + hub-cursors: list>, + subscriber-cursors: list>, + attempt-seeds: list> + } + + record group-hub-set { + active: list, + pending: list, + sync: list> + } + + type group-id = string; + + record group-member { + node-id: node-id, + role-id: string, + status: membership-status, + last-activity: u64 + } + + record group-metadata { + name: string, + description: option, + avatar: option, + creator-id: string, + created-at: u64, + updated-at: u64, + visibility: group-visibility, + default-role-id: string, + root-thread-id: thread-id + } + + type group-permissions = u64; + + record group-routing-config { + hub-topic: string, + subscriber-topic: string, + snapshot-interval-secs: u64 + } + + record group-subscriber-set { + entries: list> + } + + enum group-tier { + hub, + subscriber + } + + enum group-visibility { + private, + public + } + + record hub-sync-state { + last-state-vector: option>, + last-snapshot-digest: option, + pending-snapshot-id: option, + last-seen-ts: u64, + last-ack-seq: u64 + } + + enum membership-action-kind { + invite, + remove + } + + record membership-decision { + status: membership-decision-status, + missing-signatures: list, + reason: option + } + + enum membership-decision-status { + pending, + approved, + rejected + } + + record membership-proposal { + proposal-id: string, + candidate: node-id, + requested-role: string, + proposer: node-id, + action: membership-action-kind, + approvals: list, + rejections: list, + eligible-voters: u32, + token-support: string, + token-opposition: string + } + + record membership-rule-config { + rule-id: membership-rule-id, + params: value + } + + type membership-rule-id = string; + + enum membership-status { + pending, + active, + removed + } + + type message-id = string; + + record message-meta { + message-id: message-id, + thread-id: thread-id, + group-id: group-id, + sender: node-id, + timestamp: u64, + message-type: message-type, + body: string, + reply-to: option, + reply-in-thread: option, + reactions: list, + attachments: list + } + + record message-reaction { + emoji: string, + user: string, + timestamp: u64 + } + + record message-reaction-meta { + node-id: node-id, + emoji: string, + timestamp: u64 + } + + enum message-status { + sending, + sent, + delivered, + failed + } + + enum message-type { + text, + image, + file, + voice-note + } + + type node-id = string; + + record role { + id: string, + label: string, + permissions: group-permissions, + tier: group-tier + } + + record spider-message { + role: string, + content: spider-message-content, + tool-calls-json: option, + tool-results-json: option, + timestamp: u64 + } + + record spider-message-content { + text: option, + audio: option>, + base-six-four-audio: option + } + + record subscriber-sync-state { + last-state-vector: option>, + last-snapshot-digest: option, + last-seen-ts: u64 + } + + record thread { + id: thread-id, + group-id: group-id, + depth: u32, + parent: thread-parent-ref, + child-threads: list, + created-at: u64, + created-by: node-id, + root-message-id: option, + summary: thread-summary, + title: option, + archived: bool + } + + type thread-id = string; + + variant thread-parent-ref { + root(group-id), + thread(thread-id) + } + + record thread-summary { + message-count: u64, + last-message-id: option, + last-activity: u64, + last-sender: option + } + + record user-profile { + name: string, + profile-pic: option + } + + // Function signature for: add-group-reaction (http) + // HTTP: POST /api/add-group-reaction + // args: (req: add-group-reaction-req) + record add-group-reaction-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: add-reaction (http) + // HTTP: POST /api/add-reaction + // args: (req: add-reaction-req) + record add-reaction-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: admin-replication-state (http) + // HTTP: POST /api/admin-replication-state + // args: (req: admin-replication-state-req) + record admin-replication-state-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: admin-replication-state (local) + // args: (req: admin-replication-state-req) + // json fmt: {"AdminReplicationState": req} + record admin-replication-state-signature-local { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: admin-subscriber-events (http) + // HTTP: POST /api/admin-subscriber-events + // args: (req: subscriber-events-req) + record admin-subscriber-events-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: admin-subscriber-events (local) + // args: (req: subscriber-events-req) + // json fmt: {"AdminSubscriberEvents": req} + record admin-subscriber-events-signature-local { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: admin-whitelist (http) + // HTTP: POST /api/admin-whitelist + // args: (req: admin-whitelist-req) + record admin-whitelist-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: admin-whitelist (local) + // args: (req: admin-whitelist-req) + // json fmt: {"AdminWhitelist": req} + record admin-whitelist-signature-local { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: approve-group-membership (http) + // HTTP: POST /api/approve-group-membership + // args: (req: approve-group-membership-req) + record approve-group-membership-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: crdt-group-apply-update (http) + // HTTP: POST /api/crdt-group-apply-update + // args: (req: crdt-group-apply-req) + record crdt-group-apply-update-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: crdt-group-apply-update (local) + // args: (req: crdt-group-apply-req) + // json fmt: {"CrdtGroupApplyUpdate": req} + record crdt-group-apply-update-signature-local { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: crdt-group-apply-update (remote) + // args: (req: crdt-group-apply-req) + // json fmt: {"CrdtGroupApplyUpdate": req} + record crdt-group-apply-update-signature-remote { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: crdt-group-snapshot (http) + // HTTP: POST /api/crdt-group-snapshot + // args: (req: crdt-group-snapshot-req) + record crdt-group-snapshot-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: crdt-group-snapshot (local) + // args: (req: crdt-group-snapshot-req) + // json fmt: {"CrdtGroupSnapshot": req} + record crdt-group-snapshot-signature-local { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: crdt-group-snapshot (remote) + // args: (req: crdt-group-snapshot-req) + // json fmt: {"CrdtGroupSnapshot": req} + record crdt-group-snapshot-signature-remote { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: crdt-group-state-vector (http) + // HTTP: POST /api/crdt-group-state-vector + // args: (req: crdt-group-state-vector-req) + record crdt-group-state-vector-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: crdt-group-state-vector (local) + // args: (req: crdt-group-state-vector-req) + // json fmt: {"CrdtGroupStateVector": req} + record crdt-group-state-vector-signature-local { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: crdt-group-update (http) + // HTTP: POST /api/crdt-group-update + // args: (req: crdt-group-update-req) + record crdt-group-update-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: crdt-group-update (local) + // args: (req: crdt-group-update-req) + // json fmt: {"CrdtGroupUpdate": req} + record crdt-group-update-signature-local { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: crdt-group-update (remote) + // args: (req: crdt-group-update-req) + // json fmt: {"CrdtGroupUpdate": req} + record crdt-group-update-signature-remote { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: create-chat (http) + // HTTP: POST /api/create-chat + // args: (req: create-chat-req) + record create-chat-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: create-chat (local) + // args: (req: create-chat-req) + // json fmt: {"CreateChat": req} + record create-chat-signature-local { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: create-chat-link (http) + // HTTP: POST /api/create-chat-link + // args: (req: create-chat-link-req) + record create-chat-link-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: create-group (http) + // HTTP: POST /api/create-group + // args: (req: create-group-req) + record create-group-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: create-group-join-link (http) + // HTTP: POST /api/create-group-join-link + // args: (req: create-group-join-link-req) + record create-group-join-link-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: create-group-thread (http) + // HTTP: POST /api/create-group-thread + // args: (req: create-group-thread-req) + record create-group-thread-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: delete-chat (http) + // HTTP: POST /api/delete-chat + // args: (req: delete-chat-req) + record delete-chat-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: delete-group-message (http) + // HTTP: POST /api/delete-group-message + // args: (req: delete-group-message-req) + record delete-group-message-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: delete-message (http) + // HTTP: POST /api/delete-message + // args: (req: delete-message-req) + record delete-message-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: download-file (http) + // HTTP: POST /api/download-file + // args: (req: download-file-req) + record download-file-signature-http { + target: string, + arg-types: tuple, + returning: result, string> + } + + // Function signature for: download-group-file (http) + // HTTP: POST /api/download-group-file + // args: (req: download-group-file-req) + record download-group-file-signature-http { + target: string, + arg-types: tuple, + returning: result, string> + } + + // Function signature for: edit-group-message (http) + // HTTP: POST /api/edit-group-message + // args: (req: edit-group-message-req) + record edit-group-message-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: edit-message (http) + // HTTP: POST /api/edit-message + // args: (req: edit-message-req) + record edit-message-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: fetch-group-file (remote) + // args: (req: fetch-group-file-req) + // json fmt: {"FetchGroupFile": req} + record fetch-group-file-signature-remote { + target: address, + arg-types: tuple, + returning: result, string> + } + + // Function signature for: forward-message (http) + // HTTP: POST /api/forward-message + // args: (req: forward-message-req) + record forward-message-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: get-all-sync-hashes (http) + // HTTP: POST /api/get-all-sync-hashes + // args: none + record get-all-sync-hashes-signature-http { + target: string, + returning: result, string> + } + + // Function signature for: get-chat (http) + // HTTP: POST /api/get-chat + // args: (req: get-chat-req) + record get-chat-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: get-chat (local) + // args: (req: get-chat-req) + // json fmt: {"GetChat": req} + record get-chat-signature-local { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: get-chat-keys (http) + // HTTP: POST /api/get-chat-keys + // args: none + record get-chat-keys-signature-http { + target: string, + returning: result, string> + } + + // Function signature for: get-chats (http) + // HTTP: POST /api/get-chats + // args: none + record get-chats-signature-http { + target: string, + returning: result, string> + } + + // Function signature for: get-chats (local) + // args: none + // json fmt: {"GetChats": null} + record get-chats-signature-local { + target: address, + returning: result, string> + } + + // Function signature for: get-group (http) + // HTTP: POST /api/get-group + // args: (req: get-group-req) + record get-group-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: get-messages (http) + // HTTP: POST /api/get-messages + // args: (req: get-messages-req) + record get-messages-signature-http { + target: string, + arg-types: tuple, + returning: result, string> + } + + // Function signature for: get-messages (local) + // args: (req: get-messages-req) + // json fmt: {"GetMessages": req} + record get-messages-signature-local { + target: address, + arg-types: tuple, + returning: result, string> + } + + // Function signature for: get-profile (http) + // HTTP: POST /api/get-profile + // args: none + record get-profile-signature-http { + target: string, + returning: result + } + + // Function signature for: get-settings (http) + // HTTP: POST /api/get-settings + // args: none + record get-settings-signature-http { + target: string, + returning: result + } + + // Function signature for: get-sync-hash (http) + // HTTP: POST /api/get-sync-hash + // args: (req: get-sync-hash-req) + record get-sync-hash-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: invite-group-member (http) + // HTTP: POST /api/invite-group-member + // args: (req: invite-group-member-req) + record invite-group-member-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: join-group-link (http) + // HTTP: POST /api/join-group-link + // args: (req: join-group-link-req) + record join-group-link-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: join-group-link-remote (remote) + // args: (req: join-group-link-remote-req) + // json fmt: {"JoinGroupLinkRemote": req} + record join-group-link-remote-signature-remote { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: list-groups (http) + // HTTP: POST /api/list-groups + // args: none + record list-groups-signature-http { + target: string, + returning: result + } + + // Function signature for: push-snapshot-to-peer (http) + // HTTP: POST /api/push-snapshot-to-peer + // args: (req: push-snapshot-to-peer-req) + record push-snapshot-to-peer-signature-http { + target: string, + arg-types: tuple, + returning: result<_, string> + } + + // Function signature for: push-snapshot-to-peer (local) + // args: (req: push-snapshot-to-peer-req) + // json fmt: {"PushSnapshotToPeer": req} + record push-snapshot-to-peer-signature-local { + target: address, + arg-types: tuple, + returning: result<_, string> + } + + // Function signature for: receive-chat-creation (remote) + // args: (counterparty: string) + // json fmt: {"ReceiveChatCreation": counterparty} + record receive-chat-creation-signature-remote { + target: address, + arg-types: tuple, + returning: result<_, string> + } + + // Function signature for: receive-message (remote) + // args: (message: chat-message) + // json fmt: {"ReceiveMessage": message} + record receive-message-signature-remote { + target: address, + arg-types: tuple, + returning: result<_, string> + } + + // Function signature for: receive-message-ack (remote) + // args: (message-id: string) + // json fmt: {"ReceiveMessageAck": message_id} + record receive-message-ack-signature-remote { + target: address, + arg-types: tuple, + returning: result<_, string> + } + + // Function signature for: receive-message-deletion (remote) + // args: (message-id: string, chat-id: string) + // json fmt: {"ReceiveMessageDeletion": [message_id, chat_id]} + record receive-message-deletion-signature-remote { + target: address, + arg-types: tuple, + returning: result<_, string> + } + + // Function signature for: receive-message-edit (remote) + // args: (chat-id: string, message-id: string, new-content: string) + // json fmt: {"ReceiveMessageEdit": [chat_id, message_id, new_content]} + record receive-message-edit-signature-remote { + target: address, + arg-types: tuple, + returning: result<_, string> + } + + // Function signature for: receive-profile-update (remote) + // args: (node: string, profile: user-profile) + // json fmt: {"ReceiveProfileUpdate": [node, profile]} + record receive-profile-update-signature-remote { + target: address, + arg-types: tuple, + returning: result<_, string> + } + + // Function signature for: receive-reaction (remote) + // args: (message-id: string, emoji: string, user: string) + // json fmt: {"ReceiveReaction": [message_id, emoji, user]} + record receive-reaction-signature-remote { + target: address, + arg-types: tuple, + returning: result<_, string> + } + + // Function signature for: receive-reaction-remove (remote) + // args: (message-id: string, emoji: string, user: string) + // json fmt: {"ReceiveReactionRemove": [message_id, emoji, user]} + record receive-reaction-remove-signature-remote { + target: address, + arg-types: tuple, + returning: result<_, string> + } + + // Function signature for: remove-group-member (http) + // HTTP: POST /api/remove-group-member + // args: (req: remove-group-member-req) + record remove-group-member-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: remove-group-reaction (http) + // HTTP: POST /api/remove-group-reaction + // args: (req: remove-group-reaction-req) + record remove-group-reaction-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: remove-reaction (http) + // HTTP: POST /api/remove-reaction + // args: (req: remove-reaction-req) + record remove-reaction-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: replication-work (http) + // HTTP: POST /api/replication-work + // args: none + record replication-work-signature-http { + target: string, + returning: result<_, string> + } + + // Function signature for: replication-work (local) + // args: none + // json fmt: {"ReplicationWork": null} + record replication-work-signature-local { + target: address, + returning: result<_, string> + } + + // Function signature for: revoke-chat-key (http) + // HTTP: POST /api/revoke-chat-key + // args: (req: revoke-chat-key-req) + record revoke-chat-key-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: search-chats (http) + // HTTP: POST /api/search-chats + // args: (req: search-chats-req) + record search-chats-signature-http { + target: string, + arg-types: tuple, + returning: result, string> + } + + // Function signature for: search-index (http) + // HTTP: POST /api/search-index + // args: (req: search-index-req) + record search-index-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: send-group-message (http) + // HTTP: POST /api/send-group-message + // args: (req: send-group-message-req) + record send-group-message-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: send-group-voice-note (http) + // HTTP: POST /api/send-group-voice-note + // args: (req: send-group-voice-note-req) + record send-group-voice-note-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: send-message (http) + // HTTP: POST /api/send-message + // args: (req: send-message-req) + record send-message-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: send-message (local) + // args: (req: send-message-req) + // json fmt: {"SendMessage": req} + record send-message-signature-local { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: send-voice-note (http) + // HTTP: POST /api/send-voice-note + // args: (req: send-voice-note-req) + record send-voice-note-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: serve-file (http) + // HTTP: GET /files/* + // args: none + record serve-file-signature-http { + target: string, + returning: result, string> + } + + // Function signature for: serve-join-link (http) + // HTTP: POST /public/join-* + // args: none + record serve-join-link-signature-http { + target: string, + returning: result + } + + // Function signature for: serve-public-chat (http) + // HTTP: POST /public + // args: none + record serve-public-chat-signature-http { + target: string, + returning: result + } + + // Function signature for: spider-connect (http) + // HTTP: POST /api/spider-connect + // args: (force-new: option) + record spider-connect-signature-http { + target: string, + arg-types: tuple>, + returning: result + } + + // Function signature for: spider-get-history (http) + // HTTP: POST /api/spider-get-history + // args: none + record spider-get-history-signature-http { + target: string, + returning: result + } + + // Function signature for: spider-set-history (http) + // HTTP: POST /api/spider-set-history + // args: (request: spider-set-history-req) + record spider-set-history-signature-http { + target: string, + arg-types: tuple, + returning: result<_, string> + } + + // Function signature for: spider-status (http) + // HTTP: POST /api/spider-status + // args: none + record spider-status-signature-http { + target: string, + returning: result + } + + // Function signature for: update-chat-settings (http) + // HTTP: POST /api/update-chat-settings + // args: (req: update-chat-settings-req) + record update-chat-settings-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: update-chat-settings (local) + // args: (req: update-chat-settings-req) + // json fmt: {"UpdateChatSettings": req} + record update-chat-settings-signature-local { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: update-group-settings (http) + // HTTP: POST /api/update-group-settings + // args: (req: update-group-settings-req) + record update-group-settings-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: update-group-settings (local) + // args: (req: update-group-settings-req) + // json fmt: {"UpdateGroupSettings": req} + record update-group-settings-signature-local { + target: address, + arg-types: tuple, + returning: result + } + + // Function signature for: update-profile (http) + // HTTP: POST /api/update-profile + // args: (profile: user-profile) + record update-profile-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: update-settings (http) + // HTTP: POST /api/update-settings + // args: (settings: settings) + record update-settings-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: upload-file (http) + // HTTP: POST /api/upload-file + // args: (req: upload-file-req) + record upload-file-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: upload-group-file (http) + // HTTP: POST /api/upload-group-file + // args: (req: upload-group-file-req) + record upload-group-file-signature-http { + target: string, + arg-types: tuple, + returning: result + } + + // Function signature for: upload-profile-picture (http) + // HTTP: POST /api/upload-profile-picture + // args: (req: upload-profile-picture-req) + record upload-profile-picture-signature-http { + target: string, + arg-types: tuple, + returning: result + } +} diff --git a/hyperdrive/packages/homepage/api/types-chat-ware-dot-hypr-v0.wit b/hyperdrive/packages/homepage/api/types-chat-ware-dot-hypr-v0.wit new file mode 100644 index 000000000..ed43dc840 --- /dev/null +++ b/hyperdrive/packages/homepage/api/types-chat-ware-dot-hypr-v0.wit @@ -0,0 +1,4 @@ +world types-chat-ware-dot-hypr-v0 { + import chat; + include lib; +} \ No newline at end of file diff --git a/hyperdrive/packages/homepage/chat/Cargo.toml b/hyperdrive/packages/homepage/chat/Cargo.toml new file mode 100644 index 000000000..60fb4fd37 --- /dev/null +++ b/hyperdrive/packages/homepage/chat/Cargo.toml @@ -0,0 +1,58 @@ +[dependencies] +anyhow = "1.0" +base64 = "0.21" +flate2 = "1.0" +futures = "0.3" +process_macros = "0.1" +rand = "0.8" +serde_json = "1.0" +wit-bindgen = "0.42.1" + +[dependencies.homepage_caller_utils] +optional = true +path = "../target/homepage-caller-utils" + +[dependencies.hyperapp_macro] +version = "0.2.0" + +[dependencies.hyperware-crdt] +git = "https://github.com/hyperware-ai/hyperware-crdt" +rev = "a0aefa9" + +[dependencies.hyperware-pubsub-core] +git = "https://github.com/hyperware-ai/hyperware-pubsub" +rev = "abd30a6" + +[dependencies.hyperware_process_lib] +features = ["hyperapp"] +version = "3.0.0" + +[dependencies.serde] +features = [ + "derive", + "rc", +] +version = "1.0" + +[dev-dependencies] +rmp-serde = "1" + +[features] +caller-utils = ["homepage_caller_utils"] +disable-notifications = [] +simulation-mode = [] +test-helpers = [] + +[lib] +crate-type = [ + "cdylib", + "rlib", +] + +[package] +edition = "2021" +name = "chat" +version = "0.1.0" + +[package.metadata.component] +package = "hyperware:process" diff --git a/hyperdrive/packages/homepage/chat/src/crdt/mod.rs b/hyperdrive/packages/homepage/chat/src/crdt/mod.rs new file mode 100644 index 000000000..b0a9115a0 --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/crdt/mod.rs @@ -0,0 +1,345 @@ +use crate::ChatState; +use hyperware_crdt::yrs::StateVector; +use hyperware_crdt::{CommitteeDoc, CommitteeError}; +use serde::{Deserialize, Serialize}; + +pub mod schema; +pub use schema::compile_membership_rules; +#[allow(unused_imports)] +pub use schema::{ + AttachmentDescriptor, DeliveryCursor, DictatorRule, Group, GroupCounters, GroupDeliveryState, + GroupHubSet, GroupId, GroupMember, GroupMetadata, GroupPermissions, GroupRoutingConfig, + GroupSubscriberSet, GroupTier, GroupVisibility, HubSyncState, MembershipActionKind, + MembershipDecision, MembershipDecisionStatus, MembershipProposal, MembershipRule, + MembershipRuleBox, MembershipRuleConfig, MembershipRuleError, MembershipRuleId, + MembershipStatus, MessageId, MessageMeta, MessageReactionMeta, MultiDictatorRule, NodeId, Role, + SubscriberSyncState, TallyVoteRule, Thread, ThreadId, ThreadParentRef, ThreadSummary, + TokenThresholdRule, +}; + +/// Per-group CRDT manager responsible for syncing a single [`GroupId`] document. +pub struct GroupCrdtManager { + group_id: GroupId, + doc: CommitteeDoc, + last_state_vector: Option, +} + +impl GroupCrdtManager { + pub fn doc_id(group_id: &GroupId) -> String { + format!("chat:group:{group_id}") + } + + pub fn from_group(group_id: &GroupId, group: &Group) -> Result { + let snapshot: GroupDocState = (group_id, group).into(); + Self::from_snapshot(snapshot) + } + + /// Construct a manager with an empty document. Useful for bootstrap when we + /// expect to hydrate entirely from a remote snapshot. + pub fn from_empty(group_id: &GroupId) -> Result { + let doc = CommitteeDoc::empty(Self::doc_id(group_id))?; + Ok(Self { + group_id: group_id.clone(), + last_state_vector: None, + doc, + }) + } + + pub fn from_snapshot(snapshot: GroupDocState) -> Result { + let group_id = snapshot.group_id.clone(); + let doc = CommitteeDoc::new(Self::doc_id(&group_id), snapshot)?; + let last_state_vector = Some(doc.state_vector()); + Ok(Self { + group_id, + doc, + last_state_vector, + }) + } + + pub fn doc(&self) -> &CommitteeDoc { + &self.doc + } + + pub fn doc_mut(&mut self) -> &mut CommitteeDoc { + &mut self.doc + } + + pub fn group_id(&self) -> &GroupId { + &self.group_id + } + + pub fn last_state_vector(&self) -> Option<&StateVector> { + self.last_state_vector.as_ref() + } + + pub fn refresh_from_group(&mut self, group: &Group) -> Result<(), CommitteeError> { + let snapshot: GroupDocState = (&self.group_id, group).into(); + self.refresh_with_snapshot(snapshot) + } + + pub fn refresh_with_snapshot(&mut self, snapshot: GroupDocState) -> Result<(), CommitteeError> { + if snapshot.group_id != self.group_id { + return Err(CommitteeError::Observer(format!( + "snapshot group mismatch: expected {} got {}", + self.group_id, snapshot.group_id + ))); + } + self.doc.write_state(&snapshot)?; + self.last_state_vector = Some(self.doc.state_vector()); + Ok(()) + } + + pub fn set_last_state_vector(&mut self, sv: StateVector) { + self.last_state_vector = Some(sv); + } +} + +/// Snapshot for a single group that replicates its metadata, members, and content. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] +pub struct GroupDocState { + pub group_id: GroupId, + #[serde(default)] + pub group: Group, +} + +impl GroupDocState { + pub fn new(group_id: GroupId, group: Group) -> Self { + Self { group_id, group } + } + + pub fn apply_into(&self, runtime: &mut ChatState) { + // Preserve local delivery state (cursors) - these are local tracking data + // that shouldn't be overwritten by incoming CRDT updates + let preserved_delivery = runtime + .groups + .get(&self.group_id) + .map(|g| g.delivery.clone()) + .unwrap_or_default(); + + let mut group = self.group.clone(); + group.delivery = preserved_delivery; + + runtime.groups.insert(self.group_id.clone(), group); + runtime.invalidate_group_rules(&self.group_id); + runtime.rebuild_pubsub_for_group(&self.group_id); + } +} + +impl From<(&GroupId, &Group)> for GroupDocState { + fn from((group_id, group): (&GroupId, &Group)) -> Self { + let mut group = group.clone(); + // Delivery cursors are local-only runtime state; never replicate via CRDT snapshots. + group.delivery = GroupDeliveryState::default(); + GroupDocState::new(group_id.clone(), group) + } +} + +#[cfg(test)] +mod tests { + use super::{GroupCrdtManager, GroupDocState}; + use crate::crdt::{ + DeliveryCursor, Group, GroupCounters, GroupDeliveryState, GroupMember, GroupPermissions, + GroupRoutingConfig, GroupTier, MembershipStatus, Role, SubscriberSyncState, + }; + use crate::ChatState; + use hyperware_pubsub_core::{whitelist::NodeId as BrokerNodeId, TopicId as BrokerTopicId}; + use std::time::SystemTime; + + #[test] + fn group_manager_round_trip_writes_state() { + let group_id = "group:test".to_string(); + let mut group = Group::default(); + group.counters = GroupCounters { + next_thread: 3, + next_message: 2, + }; + let manager = + GroupCrdtManager::from_group(&group_id, &group).expect("failed to build group manager"); + + let doc_state = manager + .doc() + .read_state() + .expect("failed to read doc state"); + assert_eq!(doc_state.group_id, group_id); + assert_eq!(doc_state.group.counters.next_thread, 3); + + let mut updated_group = group.clone(); + updated_group.counters.next_thread = 5; + let mut manager = manager; + manager + .refresh_from_group(&updated_group) + .expect("refresh_from_group should succeed"); + let refreshed = manager + .doc() + .read_state() + .expect("failed to read refreshed doc"); + assert_eq!(refreshed.group.counters.next_thread, 5); + } + + #[test] + fn group_doc_state_apply_preserves_counters() { + let mut runtime = ChatState::default(); + let mut group = Group::default(); + group.counters = GroupCounters { + next_thread: 4, + next_message: 9, + }; + let snapshot = GroupDocState::new("group:apply".to_string(), group); + snapshot.apply_into(&mut runtime); + + let stored = runtime + .groups + .get("group:apply") + .expect("group should be present"); + assert_eq!(stored.counters.next_thread, 4); + assert_eq!(stored.counters.next_message, 9); + } + + #[test] + fn group_doc_state_apply_preserves_delivery_state() { + let mut runtime = ChatState::default(); + let group_id = "group:delivery".to_string(); + + let mut local_group = Group::default(); + let mut local_delivery = GroupDeliveryState::default(); + local_delivery.hub_cursors.insert( + "hub.node".into(), + DeliveryCursor { + queue_id: "hub-queue".to_string(), + last_offset: 42, + updated_at: 100, + }, + ); + local_delivery.subscriber_cursors.insert( + "sub.node".into(), + DeliveryCursor { + queue_id: "sub-queue".to_string(), + last_offset: 7, + updated_at: 200, + }, + ); + local_group.delivery = local_delivery.clone(); + runtime.groups.insert(group_id.clone(), local_group); + + let mut incoming_group = Group::default(); + incoming_group.delivery.hub_cursors.insert( + "hub.node".into(), + DeliveryCursor { + queue_id: "remote-queue".to_string(), + last_offset: 1, + updated_at: 1, + }, + ); + let snapshot = GroupDocState::new(group_id.clone(), incoming_group); + snapshot.apply_into(&mut runtime); + + let stored = runtime + .groups + .get(&group_id) + .expect("group should be present"); + assert_eq!(stored.delivery, local_delivery); + } + + #[test] + fn group_doc_state_apply_ignores_remote_delivery_on_first_apply() { + let mut runtime = ChatState::default(); + let group_id = "group:delivery-first".to_string(); + + let mut incoming_group = Group::default(); + incoming_group.delivery.hub_cursors.insert( + "hub.node".into(), + DeliveryCursor { + queue_id: "remote-queue".to_string(), + last_offset: 9, + updated_at: 900, + }, + ); + incoming_group.delivery.subscriber_cursors.insert( + "sub.node".into(), + DeliveryCursor { + queue_id: "remote-sub".to_string(), + last_offset: 3, + updated_at: 901, + }, + ); + + let snapshot = GroupDocState::new(group_id.clone(), incoming_group); + snapshot.apply_into(&mut runtime); + + let stored = runtime + .groups + .get(&group_id) + .expect("group should be present"); + assert_eq!(stored.delivery, GroupDeliveryState::default()); + } + + #[test] + fn group_doc_state_from_strips_delivery() { + let group_id = "group:delivery-from".to_string(); + let mut group = Group::default(); + group.delivery.hub_cursors.insert( + "hub.node".into(), + DeliveryCursor { + queue_id: "hub-queue".to_string(), + last_offset: 12, + updated_at: 345, + }, + ); + + let snapshot: GroupDocState = (&group_id, &group).into(); + + assert_eq!(snapshot.group.delivery, GroupDeliveryState::default()); + } + + #[test] + fn apply_into_rebuilds_pubsub_acl() { + let mut runtime = ChatState::default(); + let group_id = "group:acl".to_string(); + let mut group = Group::default(); + group.routing = GroupRoutingConfig::for_group(&group_id); + + let mut perms = GroupPermissions::empty(); + perms.insert(GroupPermissions::SEND_MESSAGES); + perms.insert(GroupPermissions::CREATE_THREADS); + let role_id = "role:member".to_string(); + + group.roles.insert( + role_id.clone(), + Role::new(role_id.clone(), "Member", perms, GroupTier::Subscriber), + ); + group.members.insert( + "member.node".into(), + GroupMember::new("member.node", role_id.clone(), MembershipStatus::Active, 0), + ); + group + .subscribers + .entries + .insert("member.node".into(), SubscriberSyncState::default()); + group.hubs.active.insert("member.node".into()); + + let snapshot = GroupDocState::new(group_id.clone(), group); + snapshot.apply_into(&mut runtime); + + let whitelist = runtime + .pubsub + .whitelist(&group_id) + .expect("whitelist projected"); + let subscriber_topic = BrokerTopicId::new( + runtime + .groups + .get(&group_id) + .unwrap() + .routing + .subscriber_topic + .clone(), + ); + + assert!(whitelist + .subscribe_scope( + &BrokerNodeId::new("member.node"), + &subscriber_topic, + SystemTime::now() + ) + .is_some()); + } +} diff --git a/hyperdrive/packages/homepage/chat/src/crdt/schema.rs b/hyperdrive/packages/homepage/chat/src/crdt/schema.rs new file mode 100644 index 000000000..2dd424e62 --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/crdt/schema.rs @@ -0,0 +1,1067 @@ +use std::collections::{HashMap, HashSet}; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::types::MessageType; + +/// Canonical identifier for a replicated group. +pub type GroupId = String; + +/// Deterministic identifier for a thread that belongs to a [`GroupId`]. +pub type ThreadId = String; + +/// Deterministic identifier for a message that belongs to a [`ThreadId`]. +pub type MessageId = String; + +/// Represents a node (peer) that can join a group. +pub type NodeId = String; + +/// Identifier for a membership rule implementation. +pub type MembershipRuleId = String; + +/// Static metadata about a group that needs to stay consistent across replicas. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupMetadata { + pub name: String, + pub description: Option, + pub avatar: Option, + pub creator_id: String, + pub created_at: u64, + pub updated_at: u64, + #[serde(default)] + pub visibility: GroupVisibility, + pub default_role_id: String, + pub root_thread_id: ThreadId, +} + +impl GroupMetadata { + #[allow(clippy::too_many_arguments)] + pub fn new( + name: impl Into, + description: Option, + avatar: Option, + creator_id: impl Into, + created_at: u64, + updated_at: u64, + visibility: GroupVisibility, + default_role_id: impl Into, + root_thread_id: ThreadId, + ) -> Self { + Self { + name: name.into(), + description, + avatar, + creator_id: creator_id.into(), + created_at, + updated_at, + visibility, + default_role_id: default_role_id.into(), + root_thread_id, + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum GroupVisibility { + Private, + #[serde(alias = "InviteOnly")] + Public, +} + +impl Default for GroupVisibility { + fn default() -> Self { + GroupVisibility::Private + } +} + +/// Tier communicates whether a role participates as a hub operator or a regular subscriber. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum GroupTier { + Hub, + Subscriber, +} + +impl Default for GroupTier { + fn default() -> Self { + GroupTier::Subscriber + } +} + +/// Simple bitset wrapper so we can extend permissions without changing serde layout. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(transparent)] +pub struct GroupPermissions(pub u64); + +impl Default for GroupPermissions { + fn default() -> Self { + GroupPermissions(0) + } +} + +impl GroupPermissions { + pub const SEND_MESSAGES: u64 = 1 << 0; + pub const CREATE_THREADS: u64 = 1 << 1; + pub const INVITE_MEMBERS: u64 = 1 << 2; + pub const MANAGE_ROLES: u64 = 1 << 3; + pub const MANAGE_SETTINGS: u64 = 1 << 4; + + pub fn empty() -> Self { + GroupPermissions(0) + } + + pub fn all() -> Self { + GroupPermissions( + Self::SEND_MESSAGES + | Self::CREATE_THREADS + | Self::INVITE_MEMBERS + | Self::MANAGE_ROLES + | Self::MANAGE_SETTINGS, + ) + } + + pub fn contains(self, flag: u64) -> bool { + self.0 & flag == flag + } + + pub fn insert(&mut self, flag: u64) { + self.0 |= flag; + } + + pub fn remove(&mut self, flag: u64) { + self.0 &= !flag; + } +} + +/// Describes a named role within a group and the permissions it grants. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Role { + pub id: String, + pub label: String, + pub permissions: GroupPermissions, + pub tier: GroupTier, +} + +impl Role { + pub fn new( + id: impl Into, + label: impl Into, + permissions: GroupPermissions, + tier: GroupTier, + ) -> Self { + Self { + id: id.into(), + label: label.into(), + permissions, + tier, + } + } +} + +/// Tracks the membership lifecycle for a node. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum MembershipStatus { + Pending, + Active, + Removed, +} + +impl Default for MembershipStatus { + fn default() -> Self { + MembershipStatus::Pending + } +} + +/// Stores the role binding and recency metadata for a specific member. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupMember { + pub node_id: NodeId, + pub role_id: String, + pub status: MembershipStatus, + pub last_activity: u64, +} + +impl GroupMember { + pub fn new( + node_id: impl Into, + role_id: impl Into, + status: MembershipStatus, + last_activity: u64, + ) -> Self { + Self { + node_id: node_id.into(), + role_id: role_id.into(), + status, + last_activity, + } + } +} + +/// Tracks hub nodes responsible for routing group traffic. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupHubSet { + #[serde(default)] + pub active: HashSet, + #[serde(default)] + pub pending: HashSet, + #[serde(default)] + pub sync: HashMap, +} + +impl GroupHubSet { + pub fn new(active: HashSet, pending: HashSet) -> Self { + Self { + active, + pending, + sync: HashMap::new(), + } + } + + pub fn upsert_sync(&mut self, node_id: NodeId, state: HubSyncState) { + self.sync.insert(node_id, state); + } +} + +/// Tracks hub replication progress, ensuring CRDT updates and snapshots propagate reliably. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct HubSyncState { + #[serde(default)] + pub last_state_vector: Option>, + #[serde(default)] + pub last_snapshot_digest: Option, + #[serde(default)] + pub pending_snapshot_id: Option, + pub last_seen_ts: u64, + #[serde(default)] + pub last_ack_seq: u64, +} + +/// Captures subscriber sync info so routing policies can avoid scanning full members list. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupSubscriberSet { + #[serde(default)] + pub entries: HashMap, +} + +impl GroupSubscriberSet { + pub fn upsert(&mut self, node_id: NodeId, state: SubscriberSyncState) { + self.entries.insert(node_id, state); + } +} + +/// Delivery cursor describing progress in a queue/attempt lane. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct DeliveryCursor { + pub queue_id: String, + #[serde(default)] + pub last_offset: u64, + pub updated_at: u64, +} + +/// Captures delivery cursor state for hubs and subscribers so queues resume cleanly. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupDeliveryState { + #[serde(default)] + pub hub_cursors: HashMap, + #[serde(default)] + pub subscriber_cursors: HashMap, + #[serde(default)] + pub attempt_seeds: HashMap, +} + +/// Topic routing configuration for a group. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupRoutingConfig { + pub hub_topic: String, + pub subscriber_topic: String, + pub snapshot_interval_secs: u64, +} + +impl GroupRoutingConfig { + pub fn new( + hub_topic: impl Into, + subscriber_topic: impl Into, + snapshot_interval_secs: u64, + ) -> Self { + Self { + hub_topic: hub_topic.into(), + subscriber_topic: subscriber_topic.into(), + snapshot_interval_secs, + } + } + + pub fn for_group(group_id: &GroupId) -> Self { + Self::new( + format!("chat.{group_id}.hubs"), + format!("chat.{group_id}.subs"), + 30, + ) + } +} + +impl Default for GroupRoutingConfig { + fn default() -> Self { + Self::new(String::new(), String::new(), 30) + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SubscriberSyncState { + #[serde(default)] + pub last_state_vector: Option>, + pub last_snapshot_digest: Option, + pub last_seen_ts: u64, +} + +/// Declarative configuration for membership rules so compiled strategies stay deterministic. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct MembershipRuleConfig { + pub rule_id: MembershipRuleId, + #[serde(default)] + pub params: Value, +} + +impl MembershipRuleConfig { + pub fn new(rule_id: impl Into, params: Value) -> Self { + Self { + rule_id: rule_id.into(), + params, + } + } +} + +/// Enumerates which membership lifecycle operation is being proposed. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum MembershipActionKind { + Invite, + Remove, +} + +impl Default for MembershipActionKind { + fn default() -> Self { + MembershipActionKind::Invite + } +} + +/// Captures the data points needed when evaluating a membership change proposal. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct MembershipProposal { + pub proposal_id: String, + pub candidate: NodeId, + pub requested_role: String, + pub proposer: NodeId, + #[serde(default)] + pub action: MembershipActionKind, + #[serde(default)] + pub approvals: HashSet, + #[serde(default)] + pub rejections: HashSet, + #[serde(default)] + pub eligible_voters: u32, + #[serde(default)] + pub token_support: u128, + #[serde(default)] + pub token_opposition: u128, +} + +impl MembershipProposal { + pub fn approval_count(&self) -> usize { + self.approvals.len() + } + + pub fn rejection_count(&self) -> usize { + self.rejections.len() + } + + pub fn outstanding_voters(&self) -> i64 { + let decided = self.approvals.len() + self.rejections.len(); + self.eligible_voters as i64 - decided as i64 + } +} + +impl Default for MembershipProposal { + fn default() -> Self { + Self { + proposal_id: String::new(), + candidate: String::new(), + requested_role: String::new(), + proposer: String::new(), + action: MembershipActionKind::Invite, + approvals: HashSet::new(), + rejections: HashSet::new(), + eligible_voters: 0, + token_support: 0, + token_opposition: 0, + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum MembershipDecisionStatus { + Pending, + Approved, + Rejected, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct MembershipDecision { + pub status: MembershipDecisionStatus, + #[serde(default)] + pub missing_signatures: Vec, + #[serde(default)] + pub reason: Option, +} + +impl MembershipDecision { + pub fn approved() -> Self { + Self { + status: MembershipDecisionStatus::Approved, + missing_signatures: Vec::new(), + reason: None, + } + } + + pub fn rejected(reason: impl Into) -> Self { + Self { + status: MembershipDecisionStatus::Rejected, + missing_signatures: Vec::new(), + reason: Some(reason.into()), + } + } + + pub fn pending(missing_signatures: Vec) -> Self { + Self { + status: MembershipDecisionStatus::Pending, + missing_signatures, + reason: None, + } + } +} + +/// Stateless trait so different rule encodings can be evaluated uniformly. +pub trait MembershipRule: Send + Sync { + fn rule_id(&self) -> &'static str; + fn required_signatures(&self, proposal: &MembershipProposal) -> Vec; + fn evaluate(&self, proposal: &MembershipProposal) -> MembershipDecision; +} + +#[derive(Debug)] +pub enum MembershipRuleError { + UnknownRule(String), + InvalidParams { rule_id: String, message: String }, +} + +impl std::fmt::Display for MembershipRuleError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MembershipRuleError::UnknownRule(rule) => { + write!(f, "unknown membership rule '{rule}'") + } + MembershipRuleError::InvalidParams { rule_id, message } => { + write!(f, "invalid params for rule '{rule_id}': {message}") + } + } + } +} + +impl std::error::Error for MembershipRuleError {} + +pub type MembershipRuleBox = Box; + +pub fn compile_membership_rules( + configs: &[MembershipRuleConfig], +) -> Result, MembershipRuleError> { + configs.iter().map(build_membership_rule).collect() +} + +fn build_membership_rule( + config: &MembershipRuleConfig, +) -> Result { + match config.rule_id.as_str() { + "membership.rule.dictator" => { + #[derive(Deserialize)] + struct Params { + dictator: NodeId, + } + let params: Params = parse_params(config)?; + Ok(Box::new(DictatorRule::new(params.dictator))) + } + "membership.rule.multi_dictator" => { + #[derive(Deserialize)] + struct Params { + dictators: Vec, + #[serde(default)] + required: Option, + } + let params: Params = parse_params(config)?; + let dictators: HashSet = params.dictators.into_iter().collect(); + let required = params.required.unwrap_or_else(|| dictators.len().max(1)); + Ok(Box::new(MultiDictatorRule::new(dictators, required))) + } + "membership.rule.tally_vote" => { + #[derive(Deserialize, Default)] + struct Params { + #[serde(default)] + quorum: Option, + } + let params: Params = if config.params.is_null() { + Params::default() + } else { + parse_params(config)? + }; + let quorum = params.quorum.unwrap_or_else(TallyVoteRule::default_quorum); + Ok(Box::new(TallyVoteRule::new(quorum))) + } + "membership.rule.token_threshold" => { + #[derive(Deserialize, Default)] + struct Params { + #[serde(default)] + min_support: Option, + #[serde(default)] + min_ratio: Option, + } + let params: Params = if config.params.is_null() { + Params::default() + } else { + parse_params(config)? + }; + Ok(Box::new(TokenThresholdRule::new( + params.min_support.unwrap_or(0), + params.min_ratio, + ))) + } + other => Err(MembershipRuleError::UnknownRule(other.to_string())), + } +} + +fn parse_params( + config: &MembershipRuleConfig, +) -> Result { + serde_json::from_value(config.params.clone()).map_err(|err| { + MembershipRuleError::InvalidParams { + rule_id: config.rule_id.clone(), + message: err.to_string(), + } + }) +} + +/// Single-operator rule where one dictator approves or rejects every request. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct DictatorRule { + pub dictator: NodeId, +} + +impl DictatorRule { + pub fn new(dictator: impl Into) -> Self { + Self { + dictator: dictator.into(), + } + } +} + +impl MembershipRule for DictatorRule { + fn rule_id(&self) -> &'static str { + "membership.rule.dictator" + } + + fn required_signatures(&self, _proposal: &MembershipProposal) -> Vec { + vec![self.dictator.clone()] + } + + fn evaluate(&self, proposal: &MembershipProposal) -> MembershipDecision { + if proposal.approvals.contains(&self.dictator) { + MembershipDecision::approved() + } else if proposal.rejections.contains(&self.dictator) { + MembershipDecision::rejected("dictator rejection") + } else { + MembershipDecision::pending(vec![self.dictator.clone()]) + } + } +} + +/// Whitelist of trusted operators where a configurable number must approve. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct MultiDictatorRule { + #[serde(default)] + pub dictators: HashSet, + pub required: usize, +} + +impl MultiDictatorRule { + pub fn new(dictators: HashSet, required: usize) -> Self { + Self { + dictators, + required, + } + } + + fn approvals_from_dictators(&self, proposal: &MembershipProposal) -> usize { + proposal + .approvals + .iter() + .filter(|id| self.dictators.contains(*id)) + .count() + } +} + +impl MembershipRule for MultiDictatorRule { + fn rule_id(&self) -> &'static str { + "membership.rule.multi_dictator" + } + + fn required_signatures(&self, _proposal: &MembershipProposal) -> Vec { + self.dictators.iter().cloned().collect() + } + + fn evaluate(&self, proposal: &MembershipProposal) -> MembershipDecision { + let approvals = self.approvals_from_dictators(proposal); + if approvals >= self.required { + return MembershipDecision::approved(); + } + + if proposal + .rejections + .iter() + .any(|node| self.dictators.contains(node)) + { + return MembershipDecision::rejected("dictator rejection"); + } + + let mut missing: Vec = self + .dictators + .iter() + .filter(|id| !proposal.approvals.contains(*id)) + .cloned() + .collect(); + missing.sort(); + MembershipDecision::pending(missing) + } +} + +/// Majority/plurality vote rule with configurable quorum ratios. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TallyVoteRule { + #[serde(default = "TallyVoteRule::default_quorum")] + pub quorum: f32, +} + +impl TallyVoteRule { + pub fn default_quorum() -> f32 { + 0.5 + } + + pub fn new(quorum: f32) -> Self { + Self { quorum } + } + + fn required_votes(&self, eligible_voters: u32) -> u32 { + if eligible_voters == 0 { + return 0; + } + let quorum = self.quorum.clamp(0.0, 1.0); + ((eligible_voters as f32 * quorum).ceil() as u32).max(1) + } +} + +impl MembershipRule for TallyVoteRule { + fn rule_id(&self) -> &'static str { + "membership.rule.tally_vote" + } + + fn required_signatures(&self, _proposal: &MembershipProposal) -> Vec { + Vec::new() + } + + fn evaluate(&self, proposal: &MembershipProposal) -> MembershipDecision { + let required = self.required_votes(proposal.eligible_voters); + let approvals = proposal.approval_count() as u32; + let rejections = proposal.rejection_count() as u32; + + if approvals >= required { + return MembershipDecision::approved(); + } + + let remaining = proposal + .eligible_voters + .saturating_sub(approvals + rejections); + if approvals + remaining < required { + return MembershipDecision::rejected("quorum unreachable"); + } + + MembershipDecision::pending(Vec::new()) + } +} + +/// Placeholder for future token-weighted rules. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TokenThresholdRule { + #[serde(default)] + pub min_support: u128, + #[serde(default)] + pub min_ratio: Option, +} + +impl TokenThresholdRule { + pub fn new(min_support: u128, min_ratio: Option) -> Self { + Self { + min_support, + min_ratio, + } + } +} + +impl MembershipRule for TokenThresholdRule { + fn rule_id(&self) -> &'static str { + "membership.rule.token_threshold" + } + + fn required_signatures(&self, _proposal: &MembershipProposal) -> Vec { + Vec::new() + } + + fn evaluate(&self, proposal: &MembershipProposal) -> MembershipDecision { + if proposal.token_support >= self.min_support { + if let Some(ratio) = self.min_ratio { + let ratio = ratio.clamp(0.0, 1.0); + let total = proposal.token_support + proposal.token_opposition; + if total > 0 { + let current_ratio = proposal.token_support as f64 / total as f64; + if current_ratio < ratio as f64 { + return MembershipDecision::pending(Vec::new()); + } + } + } + MembershipDecision::approved() + } else if proposal.token_support + proposal.token_opposition < self.min_support { + MembershipDecision::pending(Vec::new()) + } else { + MembershipDecision::pending(Vec::new()) + } + } +} + +/// Identifies the parent of a thread (either the group root or another thread). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum ThreadParentRef { + Root(GroupId), + Thread(ThreadId), +} + +/// Lightweight summary so clients can render thread previews. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ThreadSummary { + pub message_count: u64, + pub last_message_id: Option, + pub last_activity: u64, + pub last_sender: Option, +} + +/// Represents a thread (root or nested) within a group. IDs should be minted via [`GroupCounters`] +/// so replicas agree on ordering. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Thread { + pub id: ThreadId, + pub group_id: GroupId, + pub depth: u32, + pub parent: ThreadParentRef, + pub child_threads: Vec, + pub created_at: u64, + pub created_by: NodeId, + pub root_message_id: Option, + pub summary: ThreadSummary, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub archived: bool, +} + +impl Thread { + pub fn new( + id: ThreadId, + group_id: GroupId, + depth: u32, + parent: ThreadParentRef, + created_at: u64, + created_by: NodeId, + ) -> Self { + Self { + id, + group_id, + depth, + parent, + child_threads: Vec::new(), + created_at, + created_by, + root_message_id: None, + summary: ThreadSummary::default(), + title: None, + archived: false, + } + } +} + +/// Minimal attachment descriptor so replicas agree on included assets without full payloads. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AttachmentDescriptor { + pub attachment_id: String, + pub filename: String, + pub mime_type: String, + pub size_bytes: u64, + pub checksum: Option, + pub uri: Option, +} + +/// CRDT-friendly description of a group message without heavyweight content blobs. +/// Message IDs should be produced via [`GroupCounters`] to remain deterministic. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct MessageMeta { + pub message_id: MessageId, + pub thread_id: ThreadId, + pub group_id: GroupId, + pub sender: NodeId, + pub timestamp: u64, + pub message_type: MessageType, + #[serde(default)] + pub body: String, + #[serde(default)] + pub reply_to: Option, + #[serde(default)] + pub reply_in_thread: Option, + #[serde(default)] + pub reactions: Vec, + #[serde(default)] + pub attachments: Vec, +} + +impl MessageMeta { + pub fn new( + message_id: MessageId, + thread_id: ThreadId, + group_id: GroupId, + sender: NodeId, + timestamp: u64, + message_type: MessageType, + body: String, + ) -> Self { + Self { + message_id, + thread_id, + group_id, + sender, + timestamp, + message_type, + body, + reply_to: None, + reply_in_thread: None, + reactions: Vec::new(), + attachments: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct MessageReactionMeta { + pub node_id: NodeId, + pub emoji: String, + pub timestamp: u64, +} + +/// Bundles all replicated state for a single [`GroupId`]. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct Group { + #[serde(default)] + pub metadata: Option, + #[serde(default)] + pub roles: HashMap, + #[serde(default)] + pub members: HashMap, + #[serde(default)] + pub hubs: GroupHubSet, + #[serde(default)] + pub subscribers: GroupSubscriberSet, + #[serde(default)] + pub routing: GroupRoutingConfig, + #[serde(default)] + pub delivery: GroupDeliveryState, + #[serde(default)] + pub membership_rules: Vec, + #[serde(default)] + pub membership_proposals: HashMap, + #[serde(default)] + pub threads: HashMap, + #[serde(default)] + pub messages: HashMap, + #[serde(default)] + pub counters: GroupCounters, +} + +impl Group { + pub fn new(metadata: GroupMetadata) -> Self { + Self { + metadata: Some(metadata), + routing: GroupRoutingConfig::default(), + ..Self::default() + } + } +} + +/// Tracks monotonically increasing counters for deterministic thread/message IDs within a group. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupCounters { + #[serde(default)] + pub next_thread: u64, + #[serde(default)] + pub next_message: u64, +} + +impl GroupCounters { + pub fn next_thread_id(&mut self, group_id: &GroupId) -> ThreadId { + let id = Self::format_thread_id(group_id, self.next_thread); + self.next_thread += 1; + id + } + + pub fn next_message_id(&mut self, group_id: &GroupId) -> MessageId { + let id = Self::format_message_id(group_id, self.next_message); + self.next_message += 1; + id + } + + pub fn peek_thread_counter(&self) -> u64 { + self.next_thread + } + + pub fn peek_message_counter(&self) -> u64 { + self.next_message + } + + fn format_thread_id(group_id: &GroupId, counter: u64) -> ThreadId { + format!("{group_id}:thread:{counter}") + } + + fn format_message_id(group_id: &GroupId, counter: u64) -> MessageId { + format!("{group_id}:msg:{counter}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn thread_ids_are_unique_per_group() { + let mut counters = GroupCounters::default(); + let group = "group-a".to_string(); + let first = counters.next_thread_id(&group); + let second = counters.next_thread_id(&group); + + assert_ne!(first, second); + assert_eq!(counters.peek_thread_counter(), 2); + assert!(first.starts_with("group-a:thread:")); + } + + #[test] + fn message_ids_are_monotonic_per_group() { + let mut counters = GroupCounters::default(); + let group = "group-a".to_string(); + + let msg_a = counters.next_message_id(&group); + let msg_b = counters.next_message_id(&group); + + assert_ne!(msg_a, msg_b); + assert!(msg_a.starts_with("group-a:msg:")); + assert!(msg_b.starts_with("group-a:msg:")); + assert_eq!(counters.peek_message_counter(), 2); + } + + fn sample_proposal() -> MembershipProposal { + MembershipProposal { + proposal_id: "p1".into(), + candidate: "carol".into(), + requested_role: "member".into(), + proposer: "bob".into(), + action: MembershipActionKind::Invite, + eligible_voters: 5, + ..MembershipProposal::default() + } + } + + #[test] + fn dictator_rule_requires_single_signature() { + let rule = DictatorRule::new("alice"); + let mut proposal = sample_proposal(); + + let pending = rule.evaluate(&proposal); + assert_eq!(pending.status, MembershipDecisionStatus::Pending); + assert_eq!(pending.missing_signatures, vec!["alice".to_string()]); + + proposal.approvals.insert("alice".into()); + let decision = rule.evaluate(&proposal); + assert_eq!(decision.status, MembershipDecisionStatus::Approved); + } + + #[test] + fn multi_dictator_rule_tracks_missing_signatures() { + let dictators = HashSet::from_iter(["alice".into(), "dave".into()]); + let rule = MultiDictatorRule::new(dictators, 2); + let mut proposal = sample_proposal(); + proposal.approvals.insert("alice".into()); + + let decision = rule.evaluate(&proposal); + assert_eq!(decision.status, MembershipDecisionStatus::Pending); + assert_eq!(decision.missing_signatures, vec!["dave".to_string()]); + } + + #[test] + fn tally_vote_rule_rejects_if_quorum_unreachable() { + let rule = TallyVoteRule::new(0.6); + let mut proposal = sample_proposal(); + proposal.eligible_voters = 5; + proposal.approvals.insert("alice".into()); + proposal.rejections.insert("dave".into()); + proposal.rejections.insert("erin".into()); + proposal.rejections.insert("frank".into()); + + let decision = rule.evaluate(&proposal); + assert_eq!(decision.status, MembershipDecisionStatus::Rejected); + assert_eq!(decision.reason.as_deref(), Some("quorum unreachable")); + } + + #[test] + fn token_rule_approves_once_threshold_met() { + let rule = TokenThresholdRule::new(100, Some(0.6)); + let mut proposal = sample_proposal(); + proposal.token_support = 80; + proposal.token_opposition = 40; + let pending = rule.evaluate(&proposal); + assert_eq!(pending.status, MembershipDecisionStatus::Pending); + + proposal.token_support = 120; + let decision = rule.evaluate(&proposal); + assert_eq!(decision.status, MembershipDecisionStatus::Approved); + } + + #[test] + fn compile_rules_from_config() { + let configs = vec![ + MembershipRuleConfig::new("membership.rule.dictator", json!({ "dictator": "alice" })), + MembershipRuleConfig::new("membership.rule.tally_vote", json!({ "quorum": 0.75 })), + ]; + let rules = compile_membership_rules(&configs).expect("rules compile"); + assert_eq!(rules.len(), 2); + } + + #[test] + fn compile_unknown_rule_errors() { + let config = MembershipRuleConfig::new("membership.rule.unknown", json!({})); + match compile_membership_rules(&[config]) { + Ok(_) => panic!("expected compile error for unknown rule"), + Err(MembershipRuleError::UnknownRule(name)) => { + assert_eq!(name, "membership.rule.unknown") + } + Err(other) => panic!("unexpected error pattern: {:?}", other), + } + } +} diff --git a/hyperdrive/packages/homepage/chat/src/groups.rs b/hyperdrive/packages/homepage/chat/src/groups.rs new file mode 100644 index 000000000..753c2fd44 --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/groups.rs @@ -0,0 +1,762 @@ +use crate::crdt::{ + compile_membership_rules, Group, GroupId, GroupMember, GroupPermissions, GroupTier, + GroupVisibility, MembershipActionKind, MembershipDecision, MembershipDecisionStatus, + MembershipProposal, MembershipRuleBox, MembershipRuleConfig, MembershipRuleError, + MembershipStatus, MessageId, MessageMeta, MessageReactionMeta, NodeId, SubscriberSyncState, + ThreadId, +}; +use crate::types::{ + active_member_count, aggregate_rule_decisions, current_timestamp, group_root_thread_id, + membership_proposal_key, sync_member_membership_sets, +}; +use crate::ChatState; +use hyperware_process_lib::our; +use serde_json::json; +use std::collections::HashSet; + +/// Check if the membership rules are a solo dictatorship (single dictator). +/// Returns Some(dictator_node_id) if so, None otherwise. +fn get_solo_dictator(rules: &[MembershipRuleConfig]) -> Option { + if rules.len() != 1 { + return None; + } + let rule = &rules[0]; + if rule.rule_id != "membership.rule.dictator" { + return None; + } + rule.params + .get("dictator") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +/// Create multi-dictatorship rule config with the given dictators. +fn make_multi_dictator_rule(dictators: HashSet) -> MembershipRuleConfig { + let dictators_vec: Vec = dictators.into_iter().collect(); + MembershipRuleConfig::new( + "membership.rule.multi_dictator", + json!({ + "dictators": dictators_vec, + "required": 1 + }), + ) +} + +/// Create solo dictatorship rule config with a single dictator. +fn make_solo_dictator_rule(dictator: String) -> MembershipRuleConfig { + MembershipRuleConfig::new( + "membership.rule.dictator", + json!({ + "dictator": dictator + }), + ) +} + +/// Get the set of dictators from a multi-dictatorship rule. +/// Returns None if rules are not a multi-dictatorship. +fn get_multi_dictators(rules: &[MembershipRuleConfig]) -> Option> { + if rules.len() != 1 { + return None; + } + let rule = &rules[0]; + if rule.rule_id != "membership.rule.multi_dictator" { + return None; + } + rule.params + .get("dictators") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) +} + +impl ChatState { + pub fn group_rules( + &mut self, + group_id: &GroupId, + ) -> Result<&[MembershipRuleBox], MembershipRuleError> { + if !self.membership_rule_cache.contains_key(group_id) { + self.rebuild_group_rule_cache(group_id)?; + } + + Ok(self + .membership_rule_cache + .get(group_id) + .expect("group rules cache populated after rebuild") + .as_slice()) + } + + pub fn invalidate_group_rules(&mut self, group_id: &GroupId) { + self.membership_rule_cache.remove(group_id); + } + + pub fn rebuild_group_rule_cache( + &mut self, + group_id: &GroupId, + ) -> Result<(), MembershipRuleError> { + let configs = self + .groups + .get(group_id) + .map(|group| group.membership_rules.as_slice()) + .unwrap_or(&[]); + let compiled = compile_membership_rules(configs)?; + self.membership_rule_cache + .insert(group_id.clone(), compiled); + Ok(()) + } + + pub fn set_group_membership_rules( + &mut self, + group_id: GroupId, + rules: Vec, + ) { + let entry = self + .groups + .entry(group_id.clone()) + .or_insert_with(Group::default); + entry.membership_rules = rules; + self.invalidate_group_rules(&group_id); + self.commit_group_crdt_or_log(&group_id, "set_group_membership_rules"); + } + + pub fn next_group_thread_id(&mut self, group_id: &GroupId) -> Result { + let group = self + .groups + .get_mut(group_id) + .ok_or_else(|| "Group not found".to_string())?; + Ok(group.counters.next_thread_id(group_id)) + } + + pub fn next_group_message_id(&mut self, group_id: &GroupId) -> Result { + let group = self + .groups + .get_mut(group_id) + .ok_or_else(|| "Group not found".to_string())?; + Ok(group.counters.next_message_id(group_id)) + } + + pub fn send_group_message_state( + &mut self, + mut req: crate::SendGroupMessageReq, + ) -> Result { + self.require_group_permission(&req.group_id, &our().node, GroupPermissions::SEND_MESSAGES) + .map_err(|err| format!("cannot send group message: {}", err))?; + self.require_subscriber_access(&req.group_id, &our().node) + .map_err(|err| format!("cannot send group message: {}", err))?; + + let message_id = self.next_group_message_id(&req.group_id)?; + let now = current_timestamp(); + let sender = our().node.clone(); + + let message = { + let group = self + .groups + .get_mut(&req.group_id) + .ok_or_else(|| "Group not found".to_string())?; + + let thread_id = req + .thread_id + .take() + .or_else(|| group_root_thread_id(group)) + .ok_or_else(|| "Group missing root thread".to_string())?; + + let thread = group + .threads + .get_mut(&thread_id) + .ok_or_else(|| "Thread not found".to_string())?; + + let mut message = MessageMeta::new( + message_id.clone(), + thread_id.clone(), + req.group_id.clone(), + sender.clone(), + now, + req.message_type, + req.content.clone(), + ); + message.reply_to = req.reply_to.take(); + message.attachments = req.attachments.clone(); + + if thread.root_message_id.is_none() { + thread.root_message_id = Some(message_id.clone()); + } + thread.summary.message_count += 1; + thread.summary.last_message_id = Some(message_id.clone()); + thread.summary.last_activity = now; + thread.summary.last_sender = Some(sender.clone()); + + group.messages.insert(message_id.clone(), message.clone()); + if let Some(meta) = group.metadata.as_mut() { + meta.updated_at = now; + } + + let subscriber = group + .subscribers + .entries + .entry(sender.clone()) + .or_insert_with(SubscriberSyncState::default); + subscriber.last_seen_ts = now; + + message + }; + self.commit_group_crdt_or_log(&req.group_id, "send_group_message"); + self.rebuild_group_search(&req.group_id); + Ok(crate::SendGroupMessageRes { message }) + } + + pub fn edit_group_message_state( + &mut self, + req: crate::EditGroupMessageReq, + ) -> Result { + self.require_group_permission(&req.group_id, &our().node, GroupPermissions::SEND_MESSAGES) + .map_err(|err| format!("cannot edit group message: {}", err))?; + + let updated = { + let group = self + .groups + .get_mut(&req.group_id) + .ok_or_else(|| "Group not found".to_string())?; + + let message = group + .messages + .get_mut(&req.message_id) + .ok_or_else(|| "Message not found".to_string())?; + + if message.sender != our().node { + return Err("Only the sender can edit this message".to_string()); + } + + message.body = req.new_content.clone(); + + message.clone() + }; + + self.commit_group_crdt_or_log(&req.group_id, "edit_group_message"); + self.rebuild_group_search(&req.group_id); + Ok(crate::SendGroupMessageRes { message: updated }) + } + + pub fn delete_group_message_state( + &mut self, + req: crate::DeleteGroupMessageReq, + ) -> Result { + self.require_group_permission(&req.group_id, &our().node, GroupPermissions::SEND_MESSAGES) + .map_err(|err| format!("cannot delete group message: {}", err))?; + + { + let group = self + .groups + .get_mut(&req.group_id) + .ok_or_else(|| "Group not found".to_string())?; + + let message = group + .messages + .remove(&req.message_id) + .ok_or_else(|| "Message not found".to_string())?; + + if message.sender != our().node { + // restore message to avoid destructive delete when unauthorized + group.messages.insert(req.message_id.clone(), message); + return Err("Only the sender can delete this message".to_string()); + } + + if let Some(thread) = group.threads.get_mut(&message.thread_id) { + thread.summary.message_count = thread.summary.message_count.saturating_sub(1); + if thread.summary.last_message_id == Some(req.message_id.clone()) { + thread.summary.last_message_id = None; + } + } + + if let Some(meta) = group.metadata.as_mut() { + meta.updated_at = current_timestamp(); + } + } + + self.commit_group_crdt_or_log(&req.group_id, "delete_group_message"); + self.rebuild_group_search(&req.group_id); + Ok("Message deleted".to_string()) + } + + pub fn add_group_reaction_state( + &mut self, + req: crate::AddGroupReactionReq, + ) -> Result { + self.require_group_permission(&req.group_id, &our().node, GroupPermissions::SEND_MESSAGES) + .map_err(|err| format!("cannot react to group message: {}", err))?; + + { + let group = self + .groups + .get_mut(&req.group_id) + .ok_or_else(|| "Group not found".to_string())?; + + let message = group + .messages + .get_mut(&req.message_id) + .ok_or_else(|| "Message not found".to_string())?; + + // prevent duplicate reaction from same user/emoji + if message + .reactions + .iter() + .any(|r| r.node_id == our().node && r.emoji == req.emoji) + { + return Ok("Reaction already exists".to_string()); + } + + message.reactions.push(MessageReactionMeta { + node_id: our().node.clone(), + emoji: req.emoji.clone(), + timestamp: current_timestamp(), + }); + crate::log_debug!( + "[REACTION] Added reaction: msg_id={} emoji={} reactions_count={}", + req.message_id, + req.emoji, + message.reactions.len() + ); + + if let Some(meta) = group.metadata.as_mut() { + meta.updated_at = current_timestamp(); + } + } + + self.commit_group_crdt_or_log(&req.group_id, "add_group_reaction"); + Ok("Reaction added".to_string()) + } + + pub fn remove_group_reaction_state( + &mut self, + req: crate::RemoveGroupReactionReq, + ) -> Result { + self.require_group_permission(&req.group_id, &our().node, GroupPermissions::SEND_MESSAGES) + .map_err(|err| format!("cannot remove reaction: {}", err))?; + + { + let group = self + .groups + .get_mut(&req.group_id) + .ok_or_else(|| "Group not found".to_string())?; + + let message = group + .messages + .get_mut(&req.message_id) + .ok_or_else(|| "Message not found".to_string())?; + + let before = message.reactions.len(); + message + .reactions + .retain(|r| !(r.node_id == our().node && r.emoji == req.emoji)); + if message.reactions.len() == before { + return Ok("Reaction missing".to_string()); + } + + if let Some(meta) = group.metadata.as_mut() { + meta.updated_at = current_timestamp(); + } + } + + self.commit_group_crdt_or_log(&req.group_id, "remove_group_reaction"); + Ok("Reaction removed".to_string()) + } + + pub fn invite_member( + &mut self, + group_id: &GroupId, + proposer: NodeId, + candidate: NodeId, + role_id: String, + ) -> Result { + self.require_group_permission(group_id, &proposer, GroupPermissions::INVITE_MEMBERS) + .map_err(crate::MembershipActionError::PermissionDenied)?; + + let proposal_id = + membership_proposal_key(group_id, &candidate, MembershipActionKind::Invite); + let eligible_voters = { + let group = self + .groups + .get(group_id) + .ok_or_else(|| crate::MembershipActionError::GroupNotFound(group_id.clone()))?; + if let Some(member) = group.members.get(&candidate) { + if member.status != MembershipStatus::Removed { + return Err(crate::MembershipActionError::MemberExists(candidate)); + } + } + if group.membership_proposals.contains_key(&proposal_id) { + return Err(crate::MembershipActionError::ProposalExists(proposal_id)); + } + active_member_count(group) + }; + + let mut proposal = MembershipProposal { + proposal_id, + candidate, + requested_role: role_id, + proposer, + action: MembershipActionKind::Invite, + approvals: HashSet::new(), + rejections: HashSet::new(), + eligible_voters, + token_support: 0, + token_opposition: 0, + }; + proposal.approvals.insert(proposal.proposer.clone()); + self.process_membership_proposal(group_id, proposal) + } + + pub fn approve_membership( + &mut self, + group_id: &GroupId, + proposal_id: &str, + approver: NodeId, + ) -> Result { + self.require_group_permission(group_id, &approver, GroupPermissions::INVITE_MEMBERS) + .map_err(crate::MembershipActionError::PermissionDenied)?; + + let proposal = { + let group = self + .groups + .get_mut(group_id) + .ok_or_else(|| crate::MembershipActionError::GroupNotFound(group_id.clone()))?; + let proposal = group + .membership_proposals + .get_mut(proposal_id) + .ok_or_else(|| { + crate::MembershipActionError::ProposalNotFound(proposal_id.to_string()) + })?; + proposal.approvals.insert(approver); + proposal.clone() + }; + self.process_membership_proposal(group_id, proposal) + } + + pub fn remove_member( + &mut self, + group_id: &GroupId, + proposer: NodeId, + target: NodeId, + ) -> Result { + // If a member is leaving themselves, do it immediately without proposals/approvals. + if proposer == target { + let now = current_timestamp(); + let group = self + .groups + .get_mut(group_id) + .ok_or_else(|| crate::MembershipActionError::GroupNotFound(group_id.clone()))?; + + let member = group + .members + .get_mut(&target) + .ok_or_else(|| crate::MembershipActionError::MemberNotFound(target.clone()))?; + if member.status == MembershipStatus::Removed { + return Err(crate::MembershipActionError::MemberNotFound(target)); + } + + member.status = MembershipStatus::Removed; + member.last_activity = now; + if let Some(meta) = group.metadata.as_mut() { + meta.updated_at = now; + } + group.membership_proposals.remove(&membership_proposal_key( + group_id, + &target, + MembershipActionKind::Remove, + )); + sync_member_membership_sets(group, &target, now); + self.commit_group_crdt_or_log(group_id, "leave_group"); + self.rebuild_group_search(group_id); + return Ok(MembershipDecision::approved()); + } + + // Removing someone else still requires MANAGE_ROLES permission. + self.require_group_permission(group_id, &proposer, GroupPermissions::MANAGE_ROLES) + .map_err(crate::MembershipActionError::PermissionDenied)?; + + let proposal_id = membership_proposal_key(group_id, &target, MembershipActionKind::Remove); + let (eligible_voters, role_id) = { + let group = self + .groups + .get(group_id) + .ok_or_else(|| crate::MembershipActionError::GroupNotFound(group_id.clone()))?; + let member = group + .members + .get(&target) + .ok_or_else(|| crate::MembershipActionError::MemberNotFound(target.clone()))?; + if member.status == MembershipStatus::Removed { + return Err(crate::MembershipActionError::MemberNotFound(target)); + } + if group.membership_proposals.contains_key(&proposal_id) { + return Err(crate::MembershipActionError::ProposalExists(proposal_id)); + } + (active_member_count(group), member.role_id.clone()) + }; + + let mut proposal = MembershipProposal { + proposal_id, + candidate: target, + requested_role: role_id, + proposer, + action: MembershipActionKind::Remove, + approvals: HashSet::new(), + rejections: HashSet::new(), + eligible_voters, + token_support: 0, + token_opposition: 0, + }; + proposal.approvals.insert(proposal.proposer.clone()); + self.process_membership_proposal(group_id, proposal) + } + + pub fn join_public_group( + &mut self, + group_id: &GroupId, + candidate: NodeId, + ) -> Result<(), crate::MembershipActionError> { + let now = current_timestamp(); + let group = self + .groups + .get_mut(group_id) + .ok_or_else(|| crate::MembershipActionError::GroupNotFound(group_id.clone()))?; + + if let Some(member) = group.members.get(&candidate) { + if member.status == MembershipStatus::Removed { + return Err(crate::MembershipActionError::PermissionDenied( + "member was removed from group".to_string(), + )); + } + if member.status == MembershipStatus::Active { + return Ok(()); + } + } + + let visibility = group + .metadata + .as_ref() + .map(|meta| meta.visibility) + .unwrap_or(GroupVisibility::Private); + if visibility != GroupVisibility::Public { + return Err(crate::MembershipActionError::PermissionDenied( + "group is not public".to_string(), + )); + } + + let default_role_id = group + .metadata + .as_ref() + .map(|meta| meta.default_role_id.clone()) + .unwrap_or_else(|| format!("{group_id}:member")); + + let invite_proposal_id = + membership_proposal_key(group_id, &candidate, MembershipActionKind::Invite); + group.membership_proposals.remove(&invite_proposal_id); + + let entry = group + .members + .entry(candidate.clone()) + .or_insert_with(|| { + GroupMember::new( + candidate.clone(), + default_role_id.clone(), + MembershipStatus::Active, + now, + ) + }); + entry.role_id = default_role_id; + entry.status = MembershipStatus::Active; + entry.last_activity = now; + + if let Some(meta) = group.metadata.as_mut() { + meta.updated_at = now; + } + + sync_member_membership_sets(group, &candidate, now); + self.commit_group_crdt_or_log(group_id, "join_public_group"); + self.rebuild_group_search(group_id); + Ok(()) + } + + fn evaluate_membership( + &mut self, + group_id: &GroupId, + proposal: &MembershipProposal, + ) -> Result { + let rules = self.group_rules(group_id)?; + Ok(aggregate_rule_decisions(rules, proposal)) + } + + fn apply_membership_decision( + &mut self, + group_id: &GroupId, + proposal: MembershipProposal, + decision: &MembershipDecision, + now: u64, + ) -> Result<(), crate::MembershipActionError> { + // Track if we need to upgrade to multi-dictatorship after adding an Owner + let mut upgrade_to_multi_dictator: Option<(String, String)> = None; + // Track if we need to downgrade from multi-dictatorship after removing an Owner + let mut downgrade_dictators: Option> = None; + + let group = self + .groups + .get_mut(group_id) + .ok_or_else(|| crate::MembershipActionError::GroupNotFound(group_id.clone()))?; + + if let Some(meta) = group.metadata.as_mut() { + meta.updated_at = now; + } + + match proposal.action { + MembershipActionKind::Invite => match decision.status { + MembershipDecisionStatus::Approved => { + // Check if the new member's role is Hub tier (Owner) + let is_hub_role = group + .roles + .get(&proposal.requested_role) + .map(|role| role.tier == GroupTier::Hub) + .unwrap_or(false); + + // If adding a Hub member and current rules are solo dictatorship, + // we need to upgrade to multi-dictatorship + if is_hub_role { + if let Some(current_dictator) = get_solo_dictator(&group.membership_rules) { + // Only upgrade if the new member is different from the current dictator + if current_dictator != proposal.candidate { + upgrade_to_multi_dictator = + Some((current_dictator, proposal.candidate.clone())); + } + } + } + + let entry = group + .members + .entry(proposal.candidate.clone()) + .or_insert_with(|| { + GroupMember::new( + proposal.candidate.clone(), + proposal.requested_role.clone(), + MembershipStatus::Active, + now, + ) + }); + entry.role_id = proposal.requested_role.clone(); + entry.status = MembershipStatus::Active; + entry.last_activity = now; + group.membership_proposals.remove(&proposal.proposal_id); + sync_member_membership_sets(group, &proposal.candidate, now); + } + MembershipDecisionStatus::Pending => { + group + .membership_proposals + .insert(proposal.proposal_id.clone(), proposal.clone()); + let entry = group + .members + .entry(proposal.candidate.clone()) + .or_insert_with(|| { + GroupMember::new( + proposal.candidate.clone(), + proposal.requested_role.clone(), + MembershipStatus::Pending, + now, + ) + }); + entry.role_id = proposal.requested_role.clone(); + entry.status = MembershipStatus::Pending; + entry.last_activity = now; + sync_member_membership_sets(group, &proposal.candidate, now); + } + MembershipDecisionStatus::Rejected => { + group.membership_proposals.remove(&proposal.proposal_id); + group.members.remove(&proposal.candidate); + sync_member_membership_sets(group, &proposal.candidate, now); + } + }, + MembershipActionKind::Remove => match decision.status { + MembershipDecisionStatus::Approved => { + // Check if the removed member was a Hub tier (Owner) in a multi-dictatorship + let is_hub_role = group + .members + .get(&proposal.candidate) + .and_then(|m| group.roles.get(&m.role_id)) + .map(|role| role.tier == GroupTier::Hub) + .unwrap_or(false); + + if is_hub_role { + // If this is a multi-dictatorship, remove this owner from dictators + if let Some(mut dictators) = get_multi_dictators(&group.membership_rules) { + dictators.remove(&proposal.candidate); + if !dictators.is_empty() { + downgrade_dictators = Some(dictators); + } + } + } + + if let Some(member) = group.members.get_mut(&proposal.candidate) { + member.status = MembershipStatus::Removed; + member.last_activity = now; + } + group.membership_proposals.remove(&proposal.proposal_id); + sync_member_membership_sets(group, &proposal.candidate, now); + } + MembershipDecisionStatus::Pending => { + group + .membership_proposals + .insert(proposal.proposal_id.clone(), proposal.clone()); + } + MembershipDecisionStatus::Rejected => { + group.membership_proposals.remove(&proposal.proposal_id); + } + }, + } + + // If we need to upgrade to multi-dictatorship, do it now + if let Some((old_dictator, new_owner)) = upgrade_to_multi_dictator { + let mut dictators = HashSet::new(); + dictators.insert(old_dictator); + dictators.insert(new_owner); + let new_rule = make_multi_dictator_rule(dictators); + + // Update the group's membership rules + if let Some(group) = self.groups.get_mut(group_id) { + group.membership_rules = vec![new_rule]; + } + // Invalidate the rule cache so it gets recompiled + self.invalidate_group_rules(group_id); + } + + // If we need to downgrade from multi-dictatorship after removing an Owner, do it now + if let Some(remaining_dictators) = downgrade_dictators { + let new_rule = if remaining_dictators.len() == 1 { + // Only one dictator left - revert to solo dictatorship + let dictator = remaining_dictators.into_iter().next().unwrap(); + make_solo_dictator_rule(dictator) + } else { + // Multiple dictators still remain - update multi-dictatorship + make_multi_dictator_rule(remaining_dictators) + }; + + // Update the group's membership rules + if let Some(group) = self.groups.get_mut(group_id) { + group.membership_rules = vec![new_rule]; + } + // Invalidate the rule cache so it gets recompiled + self.invalidate_group_rules(group_id); + } + + Ok(()) + } + + fn process_membership_proposal( + &mut self, + group_id: &GroupId, + proposal: MembershipProposal, + ) -> Result { + let decision = self.evaluate_membership(group_id, &proposal)?; + let now = current_timestamp(); + self.apply_membership_decision(group_id, proposal, &decision, now)?; + self.commit_group_crdt_or_log(group_id, "membership_proposal"); + self.rebuild_group_search(group_id); + Ok(decision) + } +} diff --git a/hyperdrive/packages/homepage/chat/src/icon b/hyperdrive/packages/homepage/chat/src/icon new file mode 100644 index 000000000..bda011388 --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/icon @@ -0,0 +1 @@ +data:image/png;base64,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 \ No newline at end of file diff --git a/hyperdrive/packages/homepage/chat/src/lib.rs b/hyperdrive/packages/homepage/chat/src/lib.rs new file mode 100644 index 000000000..94e5a14a3 --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/lib.rs @@ -0,0 +1,4661 @@ +// HYPERWARE CHAT APPLICATION +// A mobile-first chat application for the Hyperware platform +// Supporting 1:1 DMs, Group chats (TODO), and Voice calls (TODO) + +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; +use flate2::Compression; +use futures::{channel::mpsc::UnboundedReceiver, pin_mut, select, FutureExt, StreamExt}; +use hyperapp_macro::*; +use hyperware_crdt::yrs::{Decode, Encode, StateVector}; +use base64::{engine::general_purpose, Engine as _}; +use hyperware_process_lib::{ + homepage::add_to_homepage, + http::server::WsMessageType, + hyperapp::{self, get_path, send, set_response_status, sleep, source, spawn, AppSendError, SaveOptions}, + our, vfs, Address, LazyLoadBlob, ProcessId, Request, Request as ProcessRequest, +}; +use std::cmp::Ordering; +use std::collections::{hash_map::DefaultHasher, HashMap}; +use std::hash::{Hash, Hasher}; +use std::io::{Read, Write}; +use std::sync::atomic::Ordering as AtomicOrdering; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +// Import generated RPC functions from caller-utils +use homepage_caller_utils as chat_caller_utils; +use chat_caller_utils::chat::{ + receive_chat_creation_remote_rpc, receive_message_ack_remote_rpc, + receive_message_deletion_remote_rpc, receive_message_edit_remote_rpc, + receive_message_remote_rpc, receive_profile_update_remote_rpc, receive_reaction_remote_rpc, + receive_reaction_remove_remote_rpc, +}; +use chat_caller_utils::ChatMessage as CUChatMessage; +use chat_caller_utils::UserProfile as CUUserProfile; + +mod crdt; +mod groups; +pub mod logging; +mod pubsub; +mod replication; +mod search; +mod types; +mod ws; + +pub use crdt::GroupDocState; +pub use types::*; + +#[cfg(feature = "test-helpers")] +pub mod test_exports { + pub use crate::crdt::{DeliveryCursor, Group, GroupRoutingConfig, SubscriberSyncState}; + pub use crate::types::{BrokerEnvelope, ChatState, ReplicationKind, ReplicationTask}; +} + +use crate::crdt::{ + GroupId, GroupPermissions, GroupVisibility, MembershipDecisionStatus, MembershipStatus, NodeId, +}; + +const OUR_PROCESS_ID: (&str, &str, &str) = ("chat", "homepage", "sys"); +// Replication RPC timeout to keep admin/test calls responsive. +const REPL_RPC_TIMEOUT_SECS: u64 = 2; +const ICON: &str = include_str!("./icon"); + + +// Helper function to enforce one-way status transitions +fn safe_update_message_status(current: &MessageStatus, new: MessageStatus) -> MessageStatus { + use MessageStatus::*; + + // Define valid transitions + match (current, &new) { + // From Sending, can go to Sent, Delivered, or Failed + (Sending, Sent) | (Sending, Delivered) | (Sending, Failed) => new, + + // From Sent, can only go to Delivered or Failed + (Sent, Delivered) | (Sent, Failed) => new, + + // From Delivered, cannot change (terminal state) + (Delivered, _) => { + log_debug!( + "WARNING: Attempted invalid status transition from Delivered to {:?}", + new + ); + current.clone() + } + + // From Failed, cannot change (terminal state) + (Failed, _) => { + log_debug!( + "WARNING: Attempted invalid status transition from Failed to {:?}", + new + ); + current.clone() + } + + // Any backwards transition is invalid + _ => { + log_debug!( + "WARNING: Attempted invalid status transition from {:?} to {:?}", + current, new + ); + current.clone() + } + } +} + +// Helper functions for compression +fn compress_data(data: &[u8]) -> Result, String> { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(data) + .map_err(|e| format!("Compression error: {}", e))?; + encoder + .finish() + .map_err(|e| format!("Compression finish error: {}", e)) +} + +fn decompress_data(compressed: &[u8]) -> Result, String> { + let mut decoder = GzDecoder::new(compressed); + let mut decompressed = Vec::new(); + decoder + .read_to_end(&mut decompressed) + .map_err(|e| format!("Decompression error: {}", e))?; + Ok(decompressed) +} + +// Helper functions for base64 encoding/decoding +fn base64_encode(data: &[u8]) -> String { + general_purpose::STANDARD.encode(data) +} + +fn base64_decode(input: &str) -> Result, base64::DecodeError> { + general_purpose::STANDARD.decode(input) +} + +/// Send the WIT `ReplicationWork` unit variant to ourselves. This wakes the +/// replication handler, which runs with a ~12s budget to drain queues. +async fn trigger_replication(target: Address) { + let _ = chat_caller_utils::chat::replication_work_local_rpc(&target).await; +} + +/// Push a snapshot immediately to a new member (bypassing the debounced queue). +/// This is called when a member is invited and approved, so they can bootstrap +/// without waiting for the replication scheduler's 250ms debounce. +fn spawn_immediate_snapshot_push(group_id: GroupId, peer: String) { + spawn(async move { + let self_addr = Address::from((our().node.as_str(), OUR_PROCESS_ID)); + let body = serde_json::to_vec(&serde_json::json!({ + "PushSnapshotToPeer": { + "group_id": group_id, + "peer": peer, + } + })) + .unwrap_or_default(); + let req = Request::new() + .target(self_addr) + .body(body) + .expects_response(5); + let _ = send::(req).await; + }); +} + +/// Spawn the event + timer replication scheduler. It listens for wake signals +/// (new work enqueued) and also ticks every 5s as a safety net, sending the WIT +/// `ReplicationWork` variant to our own address. The receiver side (`replication_work`) +/// enforces the 12s budget for draining tasks. +fn start_replication_scheduler(wake_rx: ReplicationWakeRx) { + let mut wake_rx = wake_rx.into_stream(); + let self_addr = Address::from((our().node.as_str(), OUR_PROCESS_ID)); + spawn(async move { + let debounce = Duration::from_millis(250); + let mut last_wake: Option = None; + loop { + let wake = wake_rx.next().fuse(); + let tick = sleep(5000).fuse(); + pin_mut!(wake, tick); + select! { + _ = wake => { + let now = Instant::now(); + let should_fire = last_wake + .map(|ts| now.duration_since(ts) >= debounce) + .unwrap_or(true); + if should_fire { + last_wake = Some(now); + trigger_replication(self_addr.clone()).await; + } + } + _ = tick => { + last_wake = None; + trigger_replication(self_addr.clone()).await; + } + } + } + }); +} + +pub(crate) fn log_crdt_event( + doc_id: &str, + context: &str, + state_vector: &StateVector, + update_len: Option, +) { + let sv_len = state_vector.len(); + match update_len { + Some(len) => log_debug!( + "[CRDT][{}] context={} state_vector_len={} update_bytes={}", + doc_id, context, sv_len, len + ), + None => log_debug!( + "[CRDT][{}] context={} state_vector_len={}", + doc_id, context, sv_len + ), + } +} + +fn log_group_state_summary(doc_id: &str, context: &str, state: &GroupDocState) { + let member_count = state.group.members.len(); + let hubs_count = state.group.hubs.active.len(); + let subs_count = state.group.subscribers.entries.len(); + let roles_count = state.group.roles.len(); + let sample_members: Vec = state.group.members.keys().take(3).cloned().collect(); + log_debug!( + "[CRDT][{}] context={} state_summary members={} hubs={} subs={} roles={} sample_members={:?}", + doc_id, context, member_count, hubs_count, subs_count, roles_count, sample_members + ); +} + +// Helper function to send push notification for a message +async fn send_push_notification_for_message(sender: &str, content: &str, chat_id: &str) { + if cfg!(feature = "disable-notifications") { + log_debug!("[NOTIFY] skipping push notification (disable-notifications feature enabled)"); + return; + } + let notify_started = Instant::now(); + // Send notification to notifications server (it will send to all registered devices) + let notifications_address = Address::new( + &our().node, + ProcessId::new(Some("notifications"), "distro", "sys"), + ); + + // Truncate message for notification + let truncated_content = if content.len() > 100 { + format!("{}...", &content[..97]) + } else { + content.to_string() + }; + + let notification_action = NotificationsAction::SendNotification { + title: format!("Message from {}", sender), + body: truncated_content, + icon: Some("/icon-180.png".to_string()), + data: Some(serde_json::json!({ + "url": format!("/chat#{}", chat_id), + "chat_id": chat_id, + "sender": sender, + "appId": "chat:homepage:sys", + "appLabel": "Chat" + })), + }; + + // Send the notification request + log_debug!("Sending notification to notifications:distro:sys"); + let request = Request::to(notifications_address.clone()) + .body(serde_json::to_vec(¬ification_action).unwrap()) + .expects_response(5); + if let Ok(body_str) = serde_json::to_string(¬ification_action) { + log_debug!( + "[NOTIFY] sending to {} body_len={} body={}", + notifications_address, + body_str.len(), + body_str + ); + } + + match send::(request).await { + Ok(resp) => { + log_debug!("Push notification response: {:?}", resp); + match resp { + NotificationsResponse::NotificationSent => { + log_debug!("Push notification sent successfully"); + } + NotificationsResponse::Err(e) => { + log_debug!("Notification server error: {}", e); + // Check if the error contains "EndpointNotValid" + if e.contains("EndpointNotValid") { + // Extract the endpoint URL from the error message + // Error format: "Failed to send to https://fcm.googleapis.com/fcm/send/...: EndpointNotValid" + if let Some(start) = e.find("https://") { + if let Some(end) = e[start..].find(':') { + let endpoint = &e[start..start + end]; + log_debug!("Removing invalid endpoint: {}", endpoint); + + // Send request to remove the invalid subscription + let remove_action = NotificationsAction::RemoveSubscription { + endpoint: endpoint.to_string(), + }; + + let remove_request = Request::to(notifications_address.clone()) + .body(serde_json::to_vec(&remove_action).unwrap()) + .expects_response(5); + log_debug!( + "[NOTIFY] removing invalid endpoint {} via {}", + endpoint, notifications_address + ); + + // Fire and forget the removal request + spawn(async move { + match send::(remove_request).await { + Ok(NotificationsResponse::SubscriptionRemoved) => { + log_debug!("Successfully removed invalid endpoint"); + } + Ok(resp) => { + log_debug!( + "Unexpected response removing endpoint: {:?}", + resp + ); + } + Err(e) => { + log_debug!("Error removing invalid endpoint: {:?}", e); + } + } + }); + } + } + } + } + _ => { + log_debug!("Unexpected notification response"); + } + } + log_debug!( + "[NOTIFY_DIAG] send_push_notification ok elapsed_ms={}", + notify_started.elapsed().as_millis() + ); + } + Err(e) => { + log_debug!("Error sending notification request: {:?}", e); + log_debug!( + "[NOTIFY_DIAG] send_push_notification err elapsed_ms={}", + notify_started.elapsed().as_millis() + ); + } + } +} + +// Helper function to send push notification for a group message +async fn send_push_notification_for_group_message( + sender: &str, + content: &str, + group_id: &str, + group_name: &str, +) { + if cfg!(feature = "disable-notifications") { + log_debug!("[NOTIFY] skipping group push notification (disable-notifications feature enabled)"); + return; + } + let notify_started = Instant::now(); + let notifications_address = Address::new( + &our().node, + ProcessId::new(Some("notifications"), "distro", "sys"), + ); + + // Truncate message for notification + let truncated_content = if content.len() > 100 { + format!("{}...", &content[..97]) + } else { + content.to_string() + }; + + let notification_action = NotificationsAction::SendNotification { + title: format!("{} in {}", sender, group_name), + body: truncated_content, + icon: Some("/icon-180.png".to_string()), + data: Some(serde_json::json!({ + "url": format!("/chat#group:{}", group_id), + "group_id": group_id, + "sender": sender, + "appId": "chat:homepage:sys", + "appLabel": "Chat" + })), + }; + + log_debug!("[NOTIFY] Sending group notification to notifications:distro:sys"); + let request = Request::to(notifications_address.clone()) + .body(serde_json::to_vec(¬ification_action).unwrap()) + .expects_response(5); + + match send::(request).await { + Ok(resp) => { + log_debug!("Group push notification response: {:?}", resp); + match resp { + NotificationsResponse::NotificationSent => { + log_debug!("Group push notification sent successfully"); + } + NotificationsResponse::Err(e) => { + log_debug!("Group notification server error: {}", e); + // Handle invalid endpoint same as DM notifications + if e.contains("EndpointNotValid") { + if let Some(start) = e.find("https://") { + if let Some(end) = e[start..].find(':') { + let endpoint = &e[start..start + end]; + log_debug!("Removing invalid endpoint: {}", endpoint); + let remove_action = NotificationsAction::RemoveSubscription { + endpoint: endpoint.to_string(), + }; + let remove_request = Request::to(notifications_address.clone()) + .body(serde_json::to_vec(&remove_action).unwrap()) + .expects_response(5); + spawn(async move { + let _ = send::(remove_request).await; + }); + } + } + } + } + _ => { + log_debug!("Unexpected group notification response"); + } + } + log_debug!( + "[NOTIFY_DIAG] send_push_notification_for_group ok elapsed_ms={}", + notify_started.elapsed().as_millis() + ); + } + Err(e) => { + log_debug!("Error sending group notification request: {:?}", e); + log_debug!( + "[NOTIFY_DIAG] send_push_notification_for_group err elapsed_ms={}", + notify_started.elapsed().as_millis() + ); + } + } +} + +// HYPERAPP IMPLEMENTATION + +#[hyperapp( + name = "Chat", + ui = Some(HttpBindingConfig::default()), + endpoints = vec![ + Binding::Http { + path: "/api", + config: HttpBindingConfig::default(), + }, + Binding::Ws { + path: "/ws", + config: WsBindingConfig::default(), + }, + Binding::Http { + path: "/public", + config: HttpBindingConfig::new(false, false, false, None) + }, + Binding::Http { + path: "/files/*", + config: HttpBindingConfig::default(), + } + ], + save_config = SaveOptions::OnDiff, + wit_world = "chat-ware-dot-hypr-v0" +)] +impl ChatState { + #[init] + async fn initialize(&mut self) { + add_to_homepage("Chat", Some(ICON), Some("/"), None); + + // Initialize with default profile + if self.profile.name == "User" { + let our_node = our().node.clone(); + self.profile.name = our_node.split('.').next().unwrap_or("User").to_string(); + } + + // Create VFS drive for storing chat files + let package_id = our().package_id(); + match vfs::create_drive(package_id, "files", Some(5)) { + Ok(drive_path) => { + log_debug!("Created files drive at: {}", drive_path); + } + Err(e) => { + log_debug!("Failed to create files drive (may already exist): {:?}", e); + } + } + + // Add a welcome chat if no chats exist + if self.chats.is_empty() { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let welcome_chat = Chat { + id: "system:welcome".to_string(), + counterparty: "System".to_string(), + messages: vec![ChatMessage { + id: format!("welcome_{}", timestamp), + sender: "System".to_string(), + content: "Welcome to Hyperware Chat! You can create new chats by clicking the + button.".to_string(), + timestamp, + sequence: Some(0), + status: MessageStatus::Delivered, + reply_to: None, + reactions: Vec::new(), + message_type: MessageType::Text, + file_info: None, + }], + last_activity: timestamp, + unread_count: 0, + is_blocked: false, + notify: false, + counterparty_profile: None, + }; + + self.chats + .insert("system:welcome".to_string(), welcome_chat); + } + + let existing_chat_ids: Vec = self.chats.keys().cloned().collect(); + for chat_id in existing_chat_ids { + self.ensure_sequence_state(&chat_id); + } + + if let Some(delivery_rx) = self.delivery_rx.take() { + let delivery_tx = self.delivery_tx.clone(); + let pending_deliveries = self.pending_deliveries.clone(); + spawn(async move { + ChatState::run_delivery_worker(delivery_rx, delivery_tx, pending_deliveries).await; + }); + } + + self.bootstrap_pending_deliveries(); + + // Kick off replication worker loop (event-driven with periodic safety net) + if let Some(wake_rx) = self.replication_wake_rx.take() { + start_replication_scheduler(wake_rx); + } + + self.rebuild_search_index(); + + log_debug!( + "Chat app initialized on node: {} with {} chats", + our().node, + self.chats.len() + ); + } + + // CHAT MANAGEMENT ENDPOINTS + + // uncomment #[remote] for tests + // #[remote] + #[local] + #[http] + async fn create_chat(&mut self, req: CreateChatReq) -> Result { + // Normalize chat ID to always be alphabetically sorted + let chat_id = Self::normalize_chat_id(&our().node, &req.counterparty); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Get counterparty profile if we have it + let counterparty_profile = self.node_profiles.get(&req.counterparty).cloned(); + + let chat = Chat { + id: chat_id.clone(), + counterparty: req.counterparty.clone(), + messages: Vec::new(), + last_activity: timestamp, + unread_count: 0, + is_blocked: false, + notify: true, + counterparty_profile, + }; + + self.chats.insert(chat_id.clone(), chat.clone()); + self.rebuild_chat_search(&chat_id); + + // Notify the counterparty about the chat creation and our profile asynchronously + let target = Address::from((req.counterparty.as_str(), OUR_PROCESS_ID)); + let our_node = our().node.clone(); + let our_profile = self.profile.clone(); + + // Spawn task to notify counterparty without blocking + spawn(async move { + // First notify about chat creation + match receive_chat_creation_remote_rpc(&target, our_node.clone()).await { + Ok(_) => log_debug!("Successfully notified counterparty about chat creation"), + Err(e) => log_debug!("Failed to notify counterparty about chat creation: {:?}", e), + } + + // Then share our profile + let cu_profile = ChatState::to_cu_user_profile(&our_profile); + match receive_profile_update_remote_rpc(&target, our_node, cu_profile).await { + Ok(_) => log_debug!("Successfully shared profile with counterparty"), + Err(e) => log_debug!("Failed to share profile with counterparty: {:?}", e), + } + }); + + Ok(chat) + } + + // uncomment #[remote] for tests + // #[remote] + #[local] + #[http] + async fn get_chats(&self) -> Result, String> { + let mut chats: Vec = self.chats.values().cloned().collect(); + log_debug!("get_chats: Returning {} chats", chats.len()); + for chat in &chats { + log_debug!(" Chat: {} with {}", chat.id, chat.counterparty); + } + chats.sort_by(|a, b| b.last_activity.cmp(&a.last_activity)); + + Ok(chats) + } + + // uncomment #[remote] for tests + // #[remote] + #[local] + #[http] + async fn get_chat(&self, req: GetChatReq) -> Result { + self.chats + .get(&req.chat_id) + .cloned() + .ok_or_else(|| "Chat not found".to_string()) + } + + // uncomment #[remote] for tests + // #[remote] + #[local] + #[http] + async fn get_messages(&self, req: GetMessagesReq) -> Result, String> { + // Get the chat + let chat = self + .chats + .get(&req.chat_id) + .ok_or_else(|| "Chat not found".to_string())?; + + // Sort by timestamp descending (newest first) and break ties via sequence/id + let mut messages: Vec = chat.messages.clone(); + messages.sort_by(|a, b| match b.timestamp.cmp(&a.timestamp) { + Ordering::Equal => match ( + b.sequence.unwrap_or(0).cmp(&a.sequence.unwrap_or(0)), + b.id.cmp(&a.id), + ) { + (Ordering::Equal, id_cmp) => id_cmp, + (seq_cmp, _) => seq_cmp, + }, + other => other, + }); + + let limit = req.limit.unwrap_or(50) as usize; + if let Some(before_ts) = req.before_timestamp { + let newer_count = messages + .iter() + .filter(|msg| msg.timestamp > before_ts) + .count(); + let mut to_skip_at_ts = limit.saturating_sub(newer_count); + messages = messages + .into_iter() + .filter(|msg| { + if msg.timestamp > before_ts { + false + } else if msg.timestamp < before_ts { + true + } else if to_skip_at_ts > 0 { + to_skip_at_ts -= 1; + false + } else { + true + } + }) + .collect(); + } + + // Apply limit (convert u64 to usize for truncate) + messages.truncate(limit); + + // Return in ascending order (oldest first) for display + messages.reverse(); + + Ok(messages) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn get_sync_hash(&self, req: GetSyncHashReq) -> Result { + let chat = self + .chats + .get(&req.chat_id) + .ok_or_else(|| "Chat not found".to_string())?; + + // Calculate a hash of the message history + let mut hasher = DefaultHasher::new(); + + // Hash message count + chat.messages.len().hash(&mut hasher); + + // Hash each message's key fields (id, sender, content, timestamp) + for msg in &chat.messages { + msg.id.hash(&mut hasher); + msg.sender.hash(&mut hasher); + msg.content.hash(&mut hasher); + msg.timestamp.hash(&mut hasher); + + // Also hash reactions to detect reaction desyncs + for reaction in &msg.reactions { + reaction.emoji.hash(&mut hasher); + reaction.user.hash(&mut hasher); + reaction.timestamp.hash(&mut hasher); + } + } + + let hash = hasher.finish(); + + Ok(SyncHashInfo { + chat_id: req.chat_id, + message_count: chat.messages.len() as u32, + last_message_id: chat.messages.last().map(|m| m.id.clone()), + last_message_timestamp: chat.messages.last().map(|m| m.timestamp), + hash: format!("{:x}", hash), + }) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn get_all_sync_hashes(&self) -> Result, String> { + let mut sync_hashes = Vec::new(); + + for (chat_id, chat) in &self.chats { + let mut hasher = DefaultHasher::new(); + + // Hash message count + chat.messages.len().hash(&mut hasher); + + // Hash each message's key fields + for msg in &chat.messages { + msg.id.hash(&mut hasher); + msg.sender.hash(&mut hasher); + msg.content.hash(&mut hasher); + msg.timestamp.hash(&mut hasher); + + // Also hash reactions + for reaction in &msg.reactions { + reaction.emoji.hash(&mut hasher); + reaction.user.hash(&mut hasher); + reaction.timestamp.hash(&mut hasher); + } + } + + let hash = hasher.finish(); + + sync_hashes.push(SyncHashInfo { + chat_id: chat_id.clone(), + message_count: chat.messages.len() as u32, + last_message_id: chat.messages.last().map(|m| m.id.clone()), + last_message_timestamp: chat.messages.last().map(|m| m.timestamp), + hash: format!("{:x}", hash), + }); + } + + Ok(sync_hashes) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn delete_chat(&mut self, req: DeleteChatReq) -> Result { + self.chats + .remove(&req.chat_id) + .ok_or_else(|| "Chat not found".to_string())?; + self.message_sequence_counters.remove(&req.chat_id); + self.rebuild_chat_search(&req.chat_id); + Ok("Chat deleted".to_string()) + } + + #[local] + #[http] + async fn update_chat_settings( + &mut self, + req: UpdateChatSettingsReq, + ) -> Result { + let chat = self + .chats + .get_mut(&req.chat_id) + .ok_or_else(|| "Chat not found".to_string())?; + + if let Some(notify) = req.notify { + chat.notify = notify; + } + + Ok(chat.clone()) + } + + // GROUP OPERATIONS + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn create_group(&mut self, req: CreateGroupReq) -> Result { + self.create_group_state(req) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn list_groups(&self) -> Result { + Ok(self.list_groups_state()) + } + + #[local] + #[http] + async fn update_group_settings( + &mut self, + req: UpdateGroupSettingsReq, + ) -> Result { + // Verify group exists + let group = self + .groups + .get(&req.group_id) + .ok_or_else(|| "Group not found".to_string())?; + + if let Some(notify) = req.notify { + self.group_notify.insert(req.group_id.clone(), notify); + } + + Ok(GroupSummary { + group_id: req.group_id.clone(), + metadata: group.metadata.clone(), + member_count: group.members.len(), + thread_count: group.threads.len(), + unread_count: self.group_unread.get(&req.group_id).copied().unwrap_or(0), + notify: self.group_notify.get(&req.group_id).copied().unwrap_or(true), + }) + } + + #[http] + async fn create_group_join_link( + &mut self, + req: CreateGroupJoinLinkReq, + ) -> Result { + self.require_group_permission(&req.group_id, &our().node, GroupPermissions::INVITE_MEMBERS) + .map_err(|err| format!("cannot create join link: {}", err))?; + + let visibility = self + .groups + .get(&req.group_id) + .and_then(|group| group.metadata.as_ref().map(|meta| meta.visibility)) + .unwrap_or(GroupVisibility::Private); + if visibility != GroupVisibility::Public { + return Err("Group is not public".to_string()); + } + + for join_key in self.group_join_keys.values_mut() { + if join_key.group_id == req.group_id { + join_key.is_revoked = true; + } + } + + let key = format!("{:x}", rand::random::()); + let join_key = GroupJoinKey { + key: key.clone(), + group_id: req.group_id.clone(), + created_at: current_timestamp(), + is_revoked: false, + }; + self.group_join_keys.insert(key.clone(), join_key); + + let link = format!( + "hw://{}/join-group/{}/{}", + our().package_id(), + our().node, + key + ); + Ok(CreateGroupJoinLinkRes { link }) + } + + #[http] + async fn join_group_link(&mut self, req: JoinGroupLinkReq) -> Result { + if req.host == our().node { + return self.join_group_link_internal(req.key, our().node.clone()); + } + + let remote_req = JoinGroupLinkRemoteReq { key: req.key }; + let body = serde_json::to_vec(&serde_json::json!({ "JoinGroupLinkRemote": remote_req })) + .map_err(|e| format!("Failed to encode join link request: {:?}", e))?; + let target = Address::from((req.host.as_str(), OUR_PROCESS_ID)); + + match send::>(Request::to(&target).body(body)).await { + Ok(Ok(res)) => Ok(res), + Ok(Err(err)) => Err(err), + Err(err) => Err(format!("Failed to join group: {:?}", err)), + } + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn get_group(&self, req: GetGroupReq) -> Result { + // Check if caller is a member of the group (Active or Removed) + // Removed members can still view the group to see their removal status + let group = self + .groups + .get(&req.group_id) + .ok_or_else(|| "group not found".to_string())?; + let caller = our().node; + let member = group + .members + .get(&caller) + .ok_or_else(|| format!("{} is not a member of group {}", caller, req.group_id))?; + // Allow Active and Removed members to view the group + // Only reject Pending members (they haven't been approved yet) + if member.status == crate::crdt::MembershipStatus::Pending { + return Err(format!( + "member {} is pending in group {} and cannot view it yet", + caller, req.group_id + )); + } + Ok(self.get_group_state(req)) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn create_group_thread( + &mut self, + req: CreateGroupThreadReq, + ) -> Result { + self.create_group_thread_state(req) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn send_group_message( + &mut self, + req: SendGroupMessageReq, + ) -> Result { + self.send_group_message_state(req) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn edit_group_message( + &mut self, + req: EditGroupMessageReq, + ) -> Result { + self.edit_group_message_state(req) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn delete_group_message(&mut self, req: DeleteGroupMessageReq) -> Result { + self.delete_group_message_state(req) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn add_group_reaction(&mut self, req: AddGroupReactionReq) -> Result { + self.add_group_reaction_state(req) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn remove_group_reaction( + &mut self, + req: RemoveGroupReactionReq, + ) -> Result { + self.remove_group_reaction_state(req) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn invite_group_member( + &mut self, + req: InviteGroupMemberReq, + ) -> Result { + let candidate = req.candidate.clone(); + let group_id = req.group_id.clone(); + let decision = self + .invite_member( + &req.group_id, + our().node.clone(), + req.candidate, + req.role_id, + ) + .map_err(|err| err.to_string())?; + + // If the invite was approved, immediately push a snapshot to the new member + // so they can bootstrap without waiting for the replication scheduler's debounce + if decision.status == MembershipDecisionStatus::Approved { + spawn_immediate_snapshot_push(group_id, candidate); + } + + Ok(MembershipDecisionRes { decision }) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn approve_group_membership( + &mut self, + req: ApproveGroupMembershipReq, + ) -> Result { + let decision = self + .approve_membership(&req.group_id, &req.proposal_id, our().node.clone()) + .map_err(|err| err.to_string())?; + Ok(MembershipDecisionRes { decision }) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn remove_group_member( + &mut self, + req: RemoveGroupMemberReq, + ) -> Result { + let decision = self + .remove_member(&req.group_id, our().node.clone(), req.member) + .map_err(|err| err.to_string())?; + Ok(MembershipDecisionRes { decision }) + } + + #[remote] + async fn join_group_link_remote( + &mut self, + req: JoinGroupLinkRemoteReq, + ) -> Result { + let caller = source().node.clone(); + let res = self.join_group_link_internal(req.key, caller.clone())?; + spawn_immediate_snapshot_push(res.group_id.clone(), caller); + Ok(res) + } + + // uncomment #[remote] for tests + // #[remote] + #[local] + #[http] + async fn send_message(&mut self, req: SendMessageReq) -> Result { + self.send_message_internal(&req.chat_id, req.content, req.reply_to, None) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn edit_message(&mut self, req: EditMessageReq) -> Result { + let mut broadcast_update: Option = None; + let mut remote_edit: Option<(String, String, String, String)> = None; + let mut needs_rebuild = false; + + if let Some(chat) = self.chats.get_mut(&req.chat_id) { + if let Some(message) = chat.messages.iter_mut().find(|m| m.id == req.message_id) { + if message.sender != our().node { + return Ok("Ignoring edit for remote message".to_string()); + } + message.content = req.new_content.clone(); + needs_rebuild = true; + broadcast_update = Some(WsServerMessage::ChatUpdate(chat.clone())); + remote_edit = Some(( + chat.counterparty.clone(), + req.chat_id.clone(), + req.message_id.clone(), + req.new_content.clone(), + )); + } + } + + if needs_rebuild { + self.rebuild_chat_search(&req.chat_id); + } + + if let Some(update) = &broadcast_update { + self.broadcast_ws_message(update); + } + + if let Some((counterparty, chat_id, message_id, new_content)) = remote_edit { + spawn(async move { + let target = Address::from((counterparty.as_str(), OUR_PROCESS_ID)); + match receive_message_edit_remote_rpc(&target, chat_id, message_id, new_content) + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => log_debug!( + "Counterparty {} rejected message edit: {}", + counterparty, err + ), + Err(err) => { + log_debug!("Failed to send message edit to {}: {:?}", counterparty, err) + } + } + }); + } + + if broadcast_update.is_some() { + return Ok("Message edited".to_string()); + } + + Err("Message not found".to_string()) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn delete_message(&mut self, req: DeleteMessageReq) -> Result { + let mut chat_update: Option = None; + let mut deletion_notice: Option<(String, String, String, bool)> = None; + let mut needs_rebuild = false; + + if let Some(chat) = self.chats.get_mut(&req.chat_id) { + if let Some(pos) = chat.messages.iter().position(|m| m.id == req.message_id) { + let counterparty = chat.counterparty.clone(); + let message_id = req.message_id.clone(); + let chat_id = req.chat_id.clone(); + let delete_for_both = req.delete_for_both.unwrap_or(false); + + chat.messages.remove(pos); + needs_rebuild = true; + + chat_update = Some(WsServerMessage::ChatUpdate(chat.clone())); + deletion_notice = Some((counterparty, message_id, chat_id, delete_for_both)); + } + } + + if needs_rebuild { + self.rebuild_chat_search(&req.chat_id); + } + + if let Some(update) = &chat_update { + self.broadcast_ws_message(update); + } + + if let Some((counterparty, message_id, chat_id, delete_for_both)) = deletion_notice { + if delete_for_both { + let target = Address::from((counterparty.as_str(), OUR_PROCESS_ID)); + spawn(async move { + let _ = receive_message_deletion_remote_rpc(&target, message_id, chat_id).await; + }); + } + return Ok("Message deleted".to_string()); + } + + Err("Message not found".to_string()) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn add_reaction(&mut self, req: AddReactionReq) -> Result { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let reaction = MessageReaction { + emoji: req.emoji.clone(), + user: our().node.clone(), + timestamp, + }; + + let mut addition: Option<(WsServerMessage, String, String, String)> = None; + + // Find and add reaction to message in the specified chat + if let Some(chat) = self.chats.get_mut(&req.chat_id) { + if let Some(message) = chat.messages.iter_mut().find(|m| m.id == req.message_id) { + // Check if user already reacted with this emoji + if !message + .reactions + .iter() + .any(|r| r.user == reaction.user && r.emoji == reaction.emoji) + { + message.reactions.push(reaction.clone()); + + let target_node = if message.sender != our().node { + message.sender.clone() + } else { + chat.counterparty.clone() + }; + + addition = Some(( + WsServerMessage::ChatUpdate(chat.clone()), + target_node, + req.message_id.clone(), + req.emoji.clone(), + )); + } else { + return Ok("Already reacted".to_string()); + } + } + } + + if let Some((chat_update, target_node, msg_id, emoji)) = addition { + self.broadcast_ws_message(&chat_update); + let user = our().node.clone(); + spawn(async move { + let target = Address::new(&target_node, OUR_PROCESS_ID.clone()); + match receive_reaction_remote_rpc(&target, msg_id, emoji, user).await { + Ok(_) => log_debug!("Successfully sent reaction to counterparty"), + Err(e) => log_debug!("Failed to send reaction to counterparty: {:?}", e), + } + }); + return Ok("Reaction added".to_string()); + } + + Err("Message not found".to_string()) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn forward_message(&mut self, req: ForwardMessageReq) -> Result { + // Find the message to forward from the specified chat + let message_to_forward = self + .chats + .get(&req.from_chat_id) + .and_then(|chat| chat.messages.iter().find(|m| m.id == req.message_id)) + .cloned(); + + let original_message = message_to_forward.ok_or_else(|| "Message not found".to_string())?; + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let chat_id = req.to_chat_id.clone(); + + let mut forwarded_message = ChatMessage { + id: format!("{}:{}", timestamp, rand::random::()), + sender: our().node.clone(), + content: format!("Forwarded: {}", original_message.content), + timestamp, + sequence: None, + status: MessageStatus::Sending, + reply_to: None, + reactions: Vec::new(), + message_type: original_message.message_type.clone(), + file_info: original_message.file_info.clone(), + }; + + self.assign_sequence_to_message(&chat_id, &mut forwarded_message); + + let (counterparty, chat_snapshot) = { + let chat = self.get_or_create_chat(&chat_id, timestamp, None, None); + chat.messages.push(forwarded_message.clone()); + chat.last_activity = timestamp; + (chat.counterparty.clone(), chat.clone()) + }; + self.rebuild_chat_search(&chat_id); + + // Send to counterparty if it's a node-to-node chat + if !chat_id.starts_with("browser:") { + let msg_to_send = forwarded_message.clone(); + + let target = Address::from((counterparty.as_str(), OUR_PROCESS_ID)); + + // Send using generated RPC method + let msg_json = serde_json::to_value(&msg_to_send).unwrap(); + let msg_for_rpc: CUChatMessage = serde_json::from_value(msg_json).unwrap(); + match receive_message_remote_rpc(&target, msg_for_rpc).await { + Ok(_) => { + if let Some(chat) = self.chats.get_mut(&req.to_chat_id) { + if let Some(msg) = chat + .messages + .iter_mut() + .find(|m| m.id == forwarded_message.id) + { + msg.status = + safe_update_message_status(&msg.status, MessageStatus::Sent); + } + + // Send ChatUpdate with the updated message status + let chat_update = WsServerMessage::ChatUpdate(chat_snapshot.clone()); + self.broadcast_ws_message(&chat_update); + } + } + Err(_) => { + self.enqueue_delivery_message(&counterparty, msg_to_send); + if let Some(chat) = self.chats.get_mut(&req.to_chat_id) { + if let Some(msg) = chat + .messages + .iter_mut() + .find(|m| m.id == forwarded_message.id) + { + msg.status = + safe_update_message_status(&msg.status, MessageStatus::Failed); + } + } + } + } + } + Ok(forwarded_message) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn remove_reaction(&mut self, req: RemoveReactionReq) -> Result { + let user = our().node.clone(); + let mut removal: Option<(WsServerMessage, String, String, String)> = None; + + // Find and remove reaction from message, and determine counterparty to notify + if let Some(chat) = self.chats.get_mut(&req.chat_id) { + if let Some(message) = chat.messages.iter_mut().find(|m| m.id == req.message_id) { + if let Some(pos) = message + .reactions + .iter() + .position(|r| r.user == user && r.emoji == req.emoji) + { + message.reactions.remove(pos); + + let target_node = if message.sender != our().node { + message.sender.clone() + } else { + chat.counterparty.clone() + }; + + removal = Some(( + WsServerMessage::ChatUpdate(chat.clone()), + target_node, + req.message_id.clone(), + req.emoji.clone(), + )); + } + } + } + + if let Some((chat_update, target_node, msg_id, emoji)) = removal { + // Update local subscribers + self.broadcast_ws_message(&chat_update); + + // Notify counterparty to remove the reaction on their copy as well + spawn(async move { + let target = Address::new(&target_node, OUR_PROCESS_ID.clone()); + match receive_reaction_remove_remote_rpc(&target, msg_id, emoji, user).await { + Ok(_) => log_debug!("Successfully sent reaction removal to counterparty"), + Err(e) => log_debug!("Failed to send reaction removal to counterparty: {:?}", e), + } + }); + + return Ok("Reaction removed".to_string()); + } + + Err("Reaction not found".to_string()) + } + + // BROWSER CHAT MANAGEMENT + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn create_chat_link(&mut self, req: CreateChatLinkReq) -> Result { + let key = format!("{:x}", rand::random::()); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let chat_key = ChatKey { + key: key.clone(), + user_name: format!("Guest-{}", rand::random::() % 10000), + created_at: timestamp, + is_revoked: false, + chat_id: req.chat_id.clone(), + }; + + self.chat_keys.insert(key.clone(), chat_key); + + let link = format!("http://{}/public/join-{}", our().node, key); + Ok(link) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn get_chat_keys(&self) -> Result, String> { + Ok(self + .chat_keys + .values() + .filter(|k| !k.is_revoked) + .cloned() + .collect()) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn revoke_chat_key(&mut self, req: RevokeChatKeyReq) -> Result { + if let Some(key) = self.chat_keys.get_mut(&req.key) { + key.is_revoked = true; + } else { + return Err("Chat key not found".to_string()); + } + Ok("Chat key revoked".to_string()) + } + + // SETTINGS + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn get_settings(&self) -> Result { + Ok(self.settings.clone()) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn update_settings(&mut self, settings: Settings) -> Result { + self.settings = settings; + Ok("Settings updated".to_string()) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn update_profile(&mut self, profile: UserProfile) -> Result { + self.profile = profile.clone(); + + // Notify all chat counterparties about the profile update + let our_node = our().node.clone(); + let counterparties: Vec = self + .chats + .values() + .map(|chat| chat.counterparty.clone()) + .collect::>() + .into_iter() + .collect(); + + for counterparty in counterparties { + let target = Address::from((counterparty.as_str(), OUR_PROCESS_ID)); + let node = our_node.clone(); + let prof = profile.clone(); + + spawn(async move { + let cu_profile = ChatState::to_cu_user_profile(&prof); + match receive_profile_update_remote_rpc(&target, node, cu_profile).await { + Ok(_) => { + // Successfully notified counterparty + } + Err(_) => { + // Counterparty is likely offline, profile will be shared when they come online + // No need to print errors as this is expected behavior + } + } + }); + } + + Ok("Profile updated".to_string()) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn upload_profile_picture( + &mut self, + req: UploadProfilePictureReq, + ) -> Result { + // Validate mime type + if !req.mime_type.starts_with("image/") { + return Err("Invalid image type".to_string()); + } + + // Store the image data as a data URL + let data_url = format!("data:{};base64,{}", req.mime_type, req.data); + self.profile.profile_pic = Some(data_url.clone()); + + // Notify all WebSocket connections about profile update + let profile_update = WsServerMessage::ProfileUpdate { + node: our().node.clone(), + profile: self.profile.clone(), + }; + self.broadcast_ws_message(&profile_update); + + // Notify all chat counterparties about the profile update + let our_node = our().node.clone(); + let counterparties: Vec = self + .chats + .values() + .map(|chat| chat.counterparty.clone()) + .collect::>() + .into_iter() + .collect(); + + for counterparty in counterparties { + let target = Address::from((counterparty.as_str(), OUR_PROCESS_ID)); + let node = our_node.clone(); + let prof = self.profile.clone(); + + spawn(async move { + let cu_profile = ChatState::to_cu_user_profile(&prof); + match receive_profile_update_remote_rpc(&target, node, cu_profile).await { + Ok(_) => log_debug!("Notified {} about profile pic update", counterparty), + Err(e) => log_debug!( + "Failed to notify {} about profile pic update: {:?}", + counterparty, e + ), + } + }); + } + + Ok(data_url) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn get_profile(&self) -> Result { + Ok(self.profile.clone()) + } + + // FILE AND VOICE NOTE OPERATIONS + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn upload_file(&mut self, req: UploadFileReq) -> Result { + // Decode base64 data + let file_data = + base64_decode(&req.data).map_err(|e| format!("Failed to decode base64: {}", e))?; + + // Check file size limit + let file_size_mb = (file_data.len() as u64) / (1024 * 1024); + if file_size_mb > self.settings.max_file_size_mb { + return Err(format!( + "File size exceeds limit of {} MB", + self.settings.max_file_size_mb + )); + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let message_id = format!("{}:{}", timestamp, rand::random::()); + + // Determine message type based on mime type + let message_type = if req.mime_type.starts_with("image/") { + MessageType::Image + } else { + MessageType::File + }; + + // Store file in VFS + let package_id = our().package_id(); + let _safe_filename = req.filename.replace("/", "_").replace("..", "_"); + let file_id = format!("{}_{}", timestamp, rand::random::()); + let vfs_path = format!( + "/{}/files/{}/{}", + package_id, + req.chat_id.replace(":", "_"), + file_id + ); + + // Create directory if it doesn't exist + let dir_path = format!("/{}/files/{}", package_id, req.chat_id.replace(":", "_")); + let _ = vfs::open_dir(&dir_path, true, Some(5)); + + // Create and write original file to VFS + let file = vfs::create_file(&vfs_path, Some(5)) + .map_err(|e| format!("Failed to create VFS file: {:?}", e))?; + file.write(&file_data) + .map_err(|e| format!("Failed to write to VFS: {:?}", e))?; + + // For images, use data URL (they're usually small enough) + // For other files, compress and send, or provide download link + let (file_url, compressed_data) = if message_type == MessageType::Image { + // Images: use data URL for easy inline display + (format!("data:{};base64,{}", req.mime_type, req.data), None) + } else { + // Files: compress and prepare for sending + let compressed = compress_data(&file_data)?; + let compressed_b64 = base64_encode(&compressed); + + // Store compressed data for sending to counterparty + // But locally, we'll serve from VFS + let local_url = format!("/files/{}/{}", req.chat_id.replace(":", "_"), file_id); + (local_url, Some(compressed_b64)) + }; + + let file_info = FileInfo { + filename: req.filename.clone(), + mime_type: req.mime_type.clone(), + size: file_data.len() as u64, + url: file_url.clone(), + }; + + let chat_id = req.chat_id.clone(); + + let message = ChatMessage { + id: message_id, + sender: our().node.clone(), + content: req.filename, + timestamp, + sequence: None, + status: MessageStatus::Sending, + reply_to: req.reply_to, + reactions: Vec::new(), + message_type: message_type.clone(), + file_info: Some(file_info), + }; + + let (counterparty, stored_message) = self.stage_outgoing_message(&chat_id, message, None); + + let mut remote_message = stored_message.clone(); + if message_type == MessageType::File { + if let Some(info) = remote_message.file_info.as_mut() { + if let Some(compressed) = compressed_data { + info.url = format!("compressed:{}", compressed); + } + } + } + + self.dispatch_outgoing_message(counterparty, remote_message); + Ok(stored_message) + } + + // uncomment #[remote] for tests + // #[remote] + #[http(method = "POST", path = "/api/download-file")] + async fn download_file(&self, req: DownloadFileReq) -> Result, String> { + if req.chat_id.contains('/') || req.chat_id.contains("..") || req.file_id.contains('/') { + set_response_status(hyperware_process_lib::http::StatusCode::BAD_REQUEST); + return Err("Invalid file path".to_string()); + } + + let caller = source().node.clone(); + let chat = self.chats.get(&req.chat_id).or_else(|| { + self.chats + .values() + .find(|chat| chat.id.replace(":", "_") == req.chat_id) + }); + let chat = chat.ok_or_else(|| { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + "Chat not found".to_string() + })?; + if caller != our().node && caller != chat.counterparty { + set_response_status(hyperware_process_lib::http::StatusCode::FORBIDDEN); + return Err("unauthorized".to_string()); + } + + let chat_id = chat.id.replace(":", "_"); + let package_id = our().package_id(); + let vfs_path = format!("/{}/files/{}/{}", package_id, chat_id, req.file_id); + + let file = vfs::open_file(&vfs_path, false, Some(5)).map_err(|e| { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + format!("Failed to open file: {:?}", e) + })?; + + let file_data = file.read().map_err(|e| { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + format!("Failed to read file: {:?}", e) + })?; + + Ok(file_data) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn upload_group_file( + &mut self, + req: UploadGroupFileReq, + ) -> Result { + self.require_group_permission(&req.group_id, &our().node, GroupPermissions::SEND_MESSAGES) + .map_err(|err| { + set_response_status(hyperware_process_lib::http::StatusCode::FORBIDDEN); + format!("unauthorized: {}", err) + })?; + self.require_subscriber_access(&req.group_id, &our().node) + .map_err(|err| { + set_response_status(hyperware_process_lib::http::StatusCode::FORBIDDEN); + format!("unauthorized: {}", err) + })?; + + let file_data = + base64_decode(&req.data).map_err(|e| format!("Failed to decode base64: {}", e))?; + + let file_size_mb = (file_data.len() as u64) / (1024 * 1024); + if file_size_mb > self.settings.max_file_size_mb { + return Err(format!( + "File size exceeds limit of {} MB", + self.settings.max_file_size_mb + )); + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let message_type = if req.mime_type.starts_with("image/") { + MessageType::Image + } else { + MessageType::File + }; + + let package_id = our().package_id(); + let file_id = format!("{}_{}", timestamp, rand::random::()); + let group_dir = req.group_id.replace(":", "_"); + let file_url = format!("/files/{}/{}", group_dir, file_id); + let vfs_path = format!("/{}/files/{}/{}", package_id, group_dir, file_id); + + let dir_path = format!("/{}/files/{}", package_id, group_dir); + let _ = vfs::open_dir(&dir_path, true, Some(5)); + + let file = vfs::create_file(&vfs_path, Some(5)) + .map_err(|e| format!("Failed to create VFS file: {:?}", e))?; + file.write(&file_data) + .map_err(|e| format!("Failed to write to VFS: {:?}", e))?; + + let attachment = crate::crdt::AttachmentDescriptor { + attachment_id: file_id, + filename: req.filename.clone(), + mime_type: req.mime_type.clone(), + size_bytes: file_data.len() as u64, + checksum: None, + uri: Some(file_url), + }; + + let send_req = SendGroupMessageReq { + group_id: req.group_id, + thread_id: req.thread_id, + content: req.filename, + message_type, + reply_to: req.reply_to, + attachments: vec![attachment], + }; + + self.send_group_message_state(send_req) + } + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn send_group_voice_note( + &mut self, + req: SendGroupVoiceNoteReq, + ) -> Result { + self.require_group_permission(&req.group_id, &our().node, GroupPermissions::SEND_MESSAGES) + .map_err(|err| { + set_response_status(hyperware_process_lib::http::StatusCode::FORBIDDEN); + format!("unauthorized: {}", err) + })?; + self.require_subscriber_access(&req.group_id, &our().node) + .map_err(|err| { + set_response_status(hyperware_process_lib::http::StatusCode::FORBIDDEN); + format!("unauthorized: {}", err) + })?; + + let file_data = base64_decode(&req.audio_data) + .map_err(|e| format!("Failed to decode base64: {}", e))?; + + let file_size_mb = (file_data.len() as u64) / (1024 * 1024); + if file_size_mb > self.settings.max_file_size_mb { + return Err(format!( + "File size exceeds limit of {} MB", + self.settings.max_file_size_mb + )); + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let message_type = MessageType::VoiceNote; + let package_id = our().package_id(); + let file_id = format!("{}_{}", timestamp, rand::random::()); + let group_dir = req.group_id.replace(":", "_"); + let file_url = format!("/files/{}/{}", group_dir, file_id); + let vfs_path = format!("/{}/files/{}/{}", package_id, group_dir, file_id); + + let dir_path = format!("/{}/files/{}", package_id, group_dir); + let _ = vfs::open_dir(&dir_path, true, Some(5)); + + let file = vfs::create_file(&vfs_path, Some(5)) + .map_err(|e| format!("Failed to create VFS file: {:?}", e))?; + file.write(&file_data) + .map_err(|e| format!("Failed to write to VFS: {:?}", e))?; + + let extension = req + .mime_type + .split('/') + .nth(1) + .and_then(|ext| ext.split(';').next()) + .unwrap_or("webm"); + let filename = format!("voice_note_{}.{}", timestamp, extension); + + let attachment = crate::crdt::AttachmentDescriptor { + attachment_id: file_id, + filename, + mime_type: req.mime_type.clone(), + size_bytes: file_data.len() as u64, + checksum: None, + uri: Some(file_url), + }; + + let content = format!("Voice note ({}s)", req.duration); + let send_req = SendGroupMessageReq { + group_id: req.group_id, + thread_id: req.thread_id, + content, + message_type, + reply_to: req.reply_to, + attachments: vec![attachment], + }; + + self.send_group_message_state(send_req) + } + + // uncomment #[remote] for tests + // #[remote] + #[http(method = "POST", path = "/api/download-group-file")] + async fn download_group_file(&mut self, req: DownloadGroupFileReq) -> Result, String> { + if req.group_id.contains('/') || req.group_id.contains("..") || req.attachment_id.contains('/') + { + set_response_status(hyperware_process_lib::http::StatusCode::BAD_REQUEST); + return Err("Invalid file path".to_string()); + } + + let caller = source().node.clone(); + self.require_subscriber_access(&req.group_id, &caller) + .map_err(|err| { + set_response_status(hyperware_process_lib::http::StatusCode::FORBIDDEN); + format!("unauthorized: {}", err) + })?; + + let group_dir = req.group_id.replace(":", "_"); + let package_id = our().package_id(); + let vfs_path = format!("/{}/files/{}/{}", package_id, group_dir, req.attachment_id); + + if let Ok(file) = vfs::open_file(&vfs_path, false, Some(5)) { + let file_data = file.read().map_err(|e| { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + format!("Failed to read file: {:?}", e) + })?; + return Ok(file_data); + } + + let sender = self + .groups + .get(&req.group_id) + .and_then(|group| { + group.messages.values().find_map(|meta| { + meta.attachments + .iter() + .any(|att| att.attachment_id == req.attachment_id) + .then(|| meta.sender.clone()) + }) + }) + .ok_or_else(|| { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + "Attachment not found".to_string() + })?; + + if sender == our().node { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + return Err("Attachment not available".to_string()); + } + + let fetch_req = FetchGroupFileReq { + group_id: req.group_id.clone(), + attachment_id: req.attachment_id.clone(), + }; + let body = serde_json::to_vec(&serde_json::json!({ "FetchGroupFile": fetch_req })) + .map_err(|e| format!("Failed to encode fetch request: {:?}", e))?; + let target = Address::from((sender.as_str(), OUR_PROCESS_ID)); + + match send::, String>>(Request::to(&target).body(body)).await { + Ok(Ok(file_data)) => { + let dir_path = format!("/{}/files/{}", package_id, group_dir); + let _ = vfs::open_dir(&dir_path, true, Some(5)); + if let Ok(file) = vfs::create_file(&vfs_path, Some(5)) { + let _ = file.write(&file_data); + } + Ok(file_data) + } + Ok(Err(err)) => { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + Err(err) + } + Err(err) => { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + Err(format!("Failed to fetch attachment: {:?}", err)) + } + } + } + + #[remote] + async fn fetch_group_file(&self, req: FetchGroupFileReq) -> Result, String> { + let caller = source().node.clone(); + self.require_subscriber_access(&req.group_id, &caller) + .map_err(|err| format!("unauthorized: {}", err))?; + + if req.group_id.contains('/') || req.group_id.contains("..") || req.attachment_id.contains('/') { + return Err("Invalid file path".to_string()); + } + + let group_dir = req.group_id.replace(":", "_"); + let package_id = our().package_id(); + let vfs_path = format!("/{}/files/{}/{}", package_id, group_dir, req.attachment_id); + + let file = vfs::open_file(&vfs_path, false, Some(5)) + .map_err(|e| format!("Failed to open file: {:?}", e))?; + let file_data = file + .read() + .map_err(|e| format!("Failed to read file: {:?}", e))?; + Ok(file_data) + } + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn send_voice_note(&mut self, req: SendVoiceNoteReq) -> Result { + let audio_bytes = base64_decode(&req.audio_data) + .map_err(|e| { + set_response_status(hyperware_process_lib::http::StatusCode::BAD_REQUEST); + format!("Failed to decode base64: {}", e) + })?; + let audio_size_mb = (audio_bytes.len() as u64) / (1024 * 1024); + if audio_size_mb > self.settings.max_file_size_mb { + set_response_status(hyperware_process_lib::http::StatusCode::PAYLOAD_TOO_LARGE); + return Err(format!( + "File size exceeds limit of {} MB", + self.settings.max_file_size_mb + )); + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let message_id = format!("{}:{}", timestamp, rand::random::()); + + // Store voice note + let file_url = format!("data:audio/webm;base64,{}", req.audio_data); + + let file_info = FileInfo { + filename: format!("voice_note_{}.webm", message_id), + mime_type: "audio/webm".to_string(), + size: audio_bytes.len() as u64, + url: file_url, + }; + + let chat_id = req.chat_id.clone(); + + let message = ChatMessage { + id: message_id, + sender: our().node.clone(), + content: format!("Voice note ({}s)", req.duration), + timestamp, + sequence: None, + status: MessageStatus::Sending, + reply_to: req.reply_to, + reactions: Vec::new(), + message_type: MessageType::VoiceNote, + file_info: Some(file_info), + }; + + let (counterparty, stored_message) = self.stage_outgoing_message(&chat_id, message, None); + self.dispatch_outgoing_message(counterparty, stored_message.clone()); + Ok(stored_message) + } + + // P2P MESSAGE RECEIVING + + #[remote] + async fn receive_chat_creation(&mut self, mut counterparty: String) -> Result<(), String> { + let caller_node = source().node.clone(); + let is_local_call = caller_node == our().node; + if !is_local_call { + if counterparty != caller_node { + log_debug!( + "[SEC] receive_chat_creation rejected spoofed counterparty={} source={}", + counterparty, caller_node + ); + return Err("receive_chat_creation rejected spoofed counterparty".to_string()); + } + counterparty = caller_node; + } + log_debug!("receive_chat_creation: Got request from {}", counterparty); + + // Normalize chat ID to always be alphabetically sorted + let chat_id = Self::normalize_chat_id(&counterparty, &our().node); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Check if chat already exists + let chat_exists = self.chats.contains_key(&chat_id); + let mut created_chat = false; + if !chat_exists { + // Get counterparty profile if we have it + let counterparty_profile = self.node_profiles.get(&counterparty).cloned(); + + let chat = Chat { + id: chat_id.clone(), + counterparty: counterparty.clone(), + messages: Vec::new(), + last_activity: timestamp, + unread_count: 0, + is_blocked: false, + notify: true, + counterparty_profile, + }; + + self.chats.insert(chat_id.clone(), chat.clone()); + self.rebuild_chat_search(&chat_id); + log_debug!("receive_chat_creation: Created chat {}", chat_id); + created_chat = true; + + // Notify WebSocket connections about the new chat + log_debug!( + "receive_chat_creation: WebSocket connections: {}", + self.ws_connections.len() + ); + let chat_update = WsServerMessage::ChatUpdate(chat.clone()); + self.broadcast_ws_message(&chat_update); + } else { + log_debug!("receive_chat_creation: Chat {} already exists", chat_id); + } + + if created_chat {} + + // Signal the delivery worker (step 3) to flush anything pending to this node + self.enqueue_delivery_flush(&counterparty); + + // Share our profile with the counterparty + let target = Address::from((counterparty.as_str(), OUR_PROCESS_ID)); + let our_node = our().node.clone(); + let our_profile = self.profile.clone(); + + spawn(async move { + let cu_profile = ChatState::to_cu_user_profile(&our_profile); + match receive_profile_update_remote_rpc(&target, our_node, cu_profile).await { + Ok(_) => { + // Successfully shared profile + } + Err(_) => { + // Counterparty is likely offline, profile will be shared when they come online + } + } + }); + + Ok(()) + } + + #[remote] + async fn receive_message(&mut self, mut message: ChatMessage) -> Result<(), String> { + let caller_node = source().node.clone(); + let is_local_call = caller_node == our().node; + if !is_local_call { + if message.sender != caller_node { + log_debug!( + "[SEC] receive_message rejected spoofed sender={} source={}", + message.sender, caller_node + ); + return Err("receive_message rejected spoofed sender".to_string()); + } + message.sender = caller_node; + } + // Find or create chat for this message - normalize the ID + let chat_id = Self::normalize_chat_id(&message.sender, &our().node); + let is_new_chat = !self.chats.contains_key(&chat_id); + let mut state_changed = false; + + self.chats.entry(chat_id.clone()).or_insert_with(|| Chat { + id: chat_id.clone(), + counterparty: message.sender.clone(), + messages: Vec::new(), + last_activity: message.timestamp, + unread_count: 0, + is_blocked: false, + notify: true, + counterparty_profile: self.node_profiles.get(&message.sender).cloned(), + }); + if is_new_chat { + state_changed = true; + } + + // Update message status to Delivered + let mut updated_message = message.clone(); + updated_message.status = + safe_update_message_status(&message.status, MessageStatus::Delivered); + updated_message.sequence = None; + + // If message has a file, save it to our VFS + if let Some(ref mut file_info) = updated_message.file_info { + let is_image = updated_message.message_type == MessageType::Image; + let original_url = file_info.url.clone(); + + let file_data = match file_info.url_kind() { + crate::types::FileUrlKind::CompressedBase64(rest) => { + let compressed_data = match base64_decode(rest) { + Ok(data) => data, + Err(e) => { + log_debug!("Failed to decode compressed file: {}", e); + vec![] + } + }; + match decompress_data(&compressed_data) { + Ok(data) => data, + Err(e) => { + log_debug!("Failed to decompress file: {}", e); + vec![] + } + } + } + crate::types::FileUrlKind::DataUrl(data_url) => { + if let Some(comma_pos) = data_url.find(',') { + let base64_data = &data_url[comma_pos + 1..]; + match base64_decode(base64_data) { + Ok(data) => data, + Err(e) => { + log_debug!("Failed to decode file data: {}", e); + vec![] + } + } + } else { + vec![] + } + } + _ => vec![], + }; + + if !file_data.is_empty() { + // Save to VFS + let package_id = our().package_id(); + let file_id = format!("{}_{}", updated_message.timestamp, rand::random::()); + let vfs_path = format!( + "/{}/files/{}/{}", + package_id, + chat_id.replace(":", "_"), + file_id + ); + + // Create directory if it doesn't exist + let dir_path = format!("/{}/files/{}", package_id, chat_id.replace(":", "_")); + let _ = vfs::open_dir(&dir_path, true, Some(5)); + + // Create and write file + if let Ok(file) = vfs::create_file(&vfs_path, Some(5)) { + let _ = file.write(&file_data); + log_debug!( + "Saved received file {} to VFS at {}", + file_info.filename, vfs_path + ); + + // For images, keep the data URL for inline display + // For files, update to local VFS path + if is_image { + // Keep the original data URL for images + file_info.url = original_url; + } else { + // Update the file URL to point to our local VFS path + file_info.url = format!("/files/{}/{}", chat_id.replace(":", "_"), file_id); + } + } + } + } + + // Deduplicate by message ID so delivery retries don't create copies + let mut is_duplicate = false; + let mut should_insert = false; + let mut stored_message: Option = None; + { + let chat = self + .chats + .get_mut(&chat_id) + .expect("chat should exist after ensure"); + if let Some(existing) = chat + .messages + .iter_mut() + .find(|m| m.id == updated_message.id) + { + is_duplicate = true; + existing.content = updated_message.content.clone(); + existing.timestamp = updated_message.timestamp; + existing.reply_to = updated_message.reply_to.clone(); + existing.reactions = updated_message.reactions.clone(); + existing.message_type = updated_message.message_type.clone(); + existing.file_info = updated_message.file_info.clone(); + existing.sender = updated_message.sender.clone(); + existing.status = + safe_update_message_status(&existing.status, updated_message.status.clone()); + state_changed = true; + } else { + should_insert = true; + } + let prev_last_activity = chat.last_activity; + chat.last_activity = chat.last_activity.max(updated_message.timestamp); + if chat.last_activity != prev_last_activity { + state_changed = true; + } + } + + if should_insert { + let mut message_to_store = updated_message.clone(); + self.assign_sequence_to_message(&chat_id, &mut message_to_store); + if let Some(chat) = self.chats.get_mut(&chat_id) { + chat.messages.push(message_to_store.clone()); + chat.unread_count += 1; + state_changed = true; + } + stored_message = Some(message_to_store); + } + + if !is_duplicate { + let message_for_events = stored_message + .clone() + .expect("new messages should be stored before broadcasting"); + let chat_snapshot = self.chats.get(&chat_id).cloned(); + // Send to WebSocket connections if any + if is_new_chat { + if let Some(chat_update) = chat_snapshot.clone() { + let msg = WsServerMessage::ChatUpdate(chat_update); + self.broadcast_ws_message(&msg); + } + } + + let msg = WsServerMessage::NewMessage(message_for_events.clone()); + self.broadcast_ws_message(&msg); + + // Send push notification if user has notifications enabled AND no active connections + let chat_notify_enabled = self + .chats + .get(&chat_id) + .map(|chat| chat.notify) + .unwrap_or(true); + let global_notify_enabled = self.settings.notify_chats; + let active_connection_count = self.active_connections.len(); + log_debug!( + "[NOTIFY] chat_push_gate chat_id={} chat_notify={} global_notify={} active_connections={}", + chat_id, + chat_notify_enabled, + global_notify_enabled, + active_connection_count + ); + if chat_notify_enabled && global_notify_enabled && active_connection_count == 0 { + let chat_id_for_push = chat_id.clone(); + let message_for_push = message_for_events.clone(); + spawn(async move { + send_push_notification_for_message( + &message_for_push.sender, + &message_for_push.content, + &chat_id_for_push, + ) + .await; + }); + } else { + log_debug!( + "[NOTIFY] chat_push_skip chat_id={} chat_notify={} global_notify={} active_connections={}", + chat_id, + chat_notify_enabled, + global_notify_enabled, + active_connection_count + ); + } + } + + if state_changed { + self.rebuild_chat_search(&chat_id); + } + + // Send acknowledgment back to sender using generated RPC + let sender = message.sender.clone(); + let msg_id = message.id.clone(); + + let target = Address::from((sender.as_str(), OUR_PROCESS_ID)); + + // Send acknowledgment using generated RPC method + let _ = receive_message_ack_remote_rpc(&target, msg_id).await; + + Ok(()) + } + // Remote handler for receiving reactions + #[remote] + async fn receive_reaction( + &mut self, + message_id: String, + emoji: String, + mut user: String, + ) -> Result<(), String> { + let caller_node = source().node.clone(); + let is_local_call = caller_node == our().node; + if !is_local_call { + if user != caller_node { + log_debug!( + "[SEC] receive_reaction rejected spoofed user={} source={}", + user, caller_node + ); + return Err("receive_reaction rejected spoofed user".to_string()); + } + user = caller_node.clone(); + } + log_debug!( + "Received reaction {} from {} for message {}", + emoji, user, message_id + ); + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let reaction = MessageReaction { + emoji: emoji.clone(), + user: user.clone(), + timestamp, + }; + + let mut update: Option = None; + + if is_local_call { + // Local calls are used for tests/debug tooling; keep broad search semantics. + for chat in self.chats.values_mut() { + if let Some(message) = chat.messages.iter_mut().find(|m| m.id == message_id) { + if !message + .reactions + .iter() + .any(|r| r.user == reaction.user && r.emoji == reaction.emoji) + { + message.reactions.push(reaction.clone()); + update = Some(WsServerMessage::ChatUpdate(chat.clone())); + } + break; + } + } + } else { + // Remote callers may only mutate chats that involve them. + let expected_chat_id = Self::normalize_chat_id(&caller_node, &our().node); + if let Some(chat) = self.chats.get_mut(&expected_chat_id) { + if let Some(message) = chat.messages.iter_mut().find(|m| m.id == message_id) { + if !message + .reactions + .iter() + .any(|r| r.user == reaction.user && r.emoji == reaction.emoji) + { + message.reactions.push(reaction.clone()); + update = Some(WsServerMessage::ChatUpdate(chat.clone())); + } + } + } + } + + if let Some(chat_update) = update { + self.broadcast_ws_message(&chat_update); + } + + // Not an error - might be a reaction for a message we don't have + Ok(()) + } + + // Remote handler for removing reactions + #[remote] + async fn receive_reaction_remove( + &mut self, + message_id: String, + emoji: String, + mut user: String, + ) -> Result<(), String> { + let caller_node = source().node.clone(); + let is_local_call = caller_node == our().node; + if !is_local_call { + if user != caller_node { + log_debug!( + "[SEC] receive_reaction_remove rejected spoofed user={} source={}", + user, caller_node + ); + return Err("receive_reaction_remove rejected spoofed user".to_string()); + } + user = caller_node.clone(); + } + log_debug!( + "Received reaction removal {} from {} for message {}", + emoji, user, message_id + ); + + let mut update: Option = None; + if is_local_call { + for chat in self.chats.values_mut() { + if let Some(message) = chat.messages.iter_mut().find(|m| m.id == message_id) { + if let Some(pos) = message + .reactions + .iter() + .position(|r| r.user == user && r.emoji == emoji) + { + message.reactions.remove(pos); + update = Some(WsServerMessage::ChatUpdate(chat.clone())); + } + break; + } + } + } else { + let expected_chat_id = Self::normalize_chat_id(&caller_node, &our().node); + if let Some(chat) = self.chats.get_mut(&expected_chat_id) { + if let Some(message) = chat.messages.iter_mut().find(|m| m.id == message_id) { + if let Some(pos) = message + .reactions + .iter() + .position(|r| r.user == user && r.emoji == emoji) + { + message.reactions.remove(pos); + update = Some(WsServerMessage::ChatUpdate(chat.clone())); + } + } + } + } + + if let Some(chat_update) = update { + self.broadcast_ws_message(&chat_update); + } + + Ok(()) + } + + #[remote] + async fn receive_message_edit( + &mut self, + chat_id: String, + message_id: String, + new_content: String, + ) -> Result<(), String> { + let caller_node = source().node.clone(); + let is_local_call = caller_node == our().node; + if !is_local_call { + let expected_chat_id = Self::normalize_chat_id(&caller_node, &our().node); + if chat_id != expected_chat_id { + log_debug!( + "[SEC] receive_message_edit rejected spoofed chat_id={} expected={} source={}", + chat_id, expected_chat_id, caller_node + ); + return Err("receive_message_edit rejected spoofed chat_id".to_string()); + } + } + let mut chat_update: Option = None; + let mut needs_rebuild = false; + + if let Some(chat) = self.chats.get_mut(&chat_id) { + if let Some(message) = chat.messages.iter_mut().find(|m| m.id == message_id) { + if !is_local_call && message.sender != caller_node { + log_debug!( + "[SEC] receive_message_edit rejected edit from {} for message sent by {}", + caller_node, message.sender + ); + return Err("receive_message_edit rejected unauthorized edit".to_string()); + } + message.content = new_content; + needs_rebuild = true; + chat_update = Some(WsServerMessage::ChatUpdate(chat.clone())); + } + } + + if needs_rebuild { + self.rebuild_chat_search(&chat_id); + } + + if let Some(update) = chat_update { + self.broadcast_ws_message(&update); + } else { + log_debug!( + "receive_message_edit: message {} in chat {} not found; dropping edit", + message_id, chat_id + ); + } + + Ok(()) + } + + // Remote handler for receiving message acknowledgments + #[remote] + async fn receive_message_ack(&mut self, message_id: String) -> Result<(), String> { + let caller_node = source().node.clone(); + let is_local_call = caller_node == our().node; + log_debug!("Received ACK for message {}", message_id); + // This ACK is from the remote node confirming they received our message + // We need to find OUR sent message and update its status to Delivered + + let mut update_payload: Option<(String, WsServerMessage)> = None; + + for chat in self.chats.values_mut() { + if !is_local_call && chat.counterparty != caller_node { + continue; + } + if let Some(message) = chat + .messages + .iter_mut() + .find(|m| m.id == message_id && m.sender == our().node) + { + log_debug!("Updating sent message {} status to Delivered", message_id); + message.status = + safe_update_message_status(&message.status, MessageStatus::Delivered); + update_payload = Some(( + chat.counterparty.clone(), + WsServerMessage::ChatUpdate(chat.clone()), + )); + break; + } + } + + if let Some((counterparty, chat_update)) = update_payload { + self.enqueue_delivery_flush(&counterparty); + + // Send ChatUpdate with the delivered status + for &channel_id in self.ws_connections.keys() { + log_debug!( + "Sending ChatUpdate for delivered message to channel {}", + channel_id + ); + self.push_ws_message(channel_id, &chat_update); + } + return Ok(()); + } + log_debug!("Sent message {} not found for ACK", message_id); + // Not an error - might be an ACK for a message we don't have anymore + Ok(()) + } + + #[remote] + async fn receive_message_deletion( + &mut self, + message_id: String, + chat_id: String, + ) -> Result<(), String> { + let caller_node = source().node.clone(); + let is_local_call = caller_node == our().node; + if !is_local_call { + let expected_chat_id = Self::normalize_chat_id(&caller_node, &our().node); + if chat_id != expected_chat_id { + log_debug!( + "[SEC] receive_message_deletion rejected spoofed chat_id={} expected={} source={}", + chat_id, expected_chat_id, caller_node + ); + return Err("receive_message_deletion rejected spoofed chat_id".to_string()); + } + } + log_debug!( + "Received deletion request for message {} in chat {}", + message_id, chat_id + ); + + let mut chat_update: Option = None; + let mut needs_rebuild = false; + + if let Some(chat) = self.chats.get_mut(&chat_id) { + if let Some(pos) = chat.messages.iter().position(|m| m.id == message_id) { + if !is_local_call && chat.messages[pos].sender != caller_node { + log_debug!( + "[SEC] receive_message_deletion rejected delete from {} for message sent by {}", + caller_node, chat.messages[pos].sender + ); + return Err("receive_message_deletion rejected unauthorized delete".to_string()); + } + chat.messages.remove(pos); + needs_rebuild = true; + log_debug!("Deleted message {} from chat {}", message_id, chat_id); + chat_update = Some(WsServerMessage::ChatUpdate(chat.clone())); + } + } + + if needs_rebuild { + self.rebuild_chat_search(&chat_id); + } + + if let Some(update) = chat_update { + self.broadcast_ws_message(&update); + } + + Ok(()) + } + + #[remote] + async fn receive_profile_update( + &mut self, + mut node: String, + profile: UserProfile, + ) -> Result<(), String> { + let caller_node = source().node.clone(); + let is_local_call = caller_node == our().node; + if !is_local_call { + if node != caller_node { + log_debug!( + "[SEC] receive_profile_update rejected spoofed node={} source={}", + node, caller_node + ); + return Err("receive_profile_update rejected spoofed node".to_string()); + } + node = caller_node; + } + log_debug!("Received profile update from {}: {:?}", node, profile); + + // Store the profile + self.node_profiles.insert(node.clone(), profile.clone()); + + // Update all chats with this counterparty + let mut updates = Vec::new(); + for chat in self.chats.values_mut() { + if chat.counterparty == node { + chat.counterparty_profile = Some(profile.clone()); + updates.push(WsServerMessage::ChatUpdate(chat.clone())); + } + } + for update in updates { + self.broadcast_ws_message(&update); + } + + Ok(()) + } + + // PUBLIC BROWSER CHAT ENDPOINTS + + #[http(path = "/public")] + async fn serve_public_chat(&self) -> Result { + // Serve the browser chat HTML + Ok(include_str!("../../ui/public/browser-chat.html").to_string()) + } + + #[http(path = "/public/join-*")] + async fn serve_join_link(&self) -> Result { + // Serve the browser chat HTML for join links + Ok(include_str!("../../ui/public/browser-chat.html").to_string()) + } + + #[http(method = "GET", path = "/files/*")] + async fn serve_file(&self) -> Result, String> { + let path = match get_path() { + Some(path) => path, + None => { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + return Err("Invalid file path".to_string()); + } + }; + + let rest = match path.strip_prefix("/files/") { + Some(rest) => rest, + None => { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + return Err("Invalid file path".to_string()); + } + }; + + let mut segments = rest.split('/'); + let chat_id = match segments.next() { + Some(chat_id) if !chat_id.is_empty() => chat_id, + _ => { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + return Err("Invalid file path".to_string()); + } + }; + let file_id = match segments.next() { + Some(file_id) if !file_id.is_empty() => file_id, + _ => { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + return Err("Invalid file path".to_string()); + } + }; + + // Build VFS path + let package_id = our().package_id(); + let vfs_path = format!("/{}/files/{}/{}", package_id, chat_id, file_id); + + // Read file from VFS + let file = vfs::open_file(&vfs_path, false, Some(5)).map_err(|e| { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + format!("Failed to open file: {:?}", e) + })?; + + let file_data = file.read().map_err(|e| { + set_response_status(hyperware_process_lib::http::StatusCode::NOT_FOUND); + format!("Failed to read file: {:?}", e) + })?; + + Ok(file_data) + } + // SEARCH + + // uncomment #[remote] for tests + // #[remote] + #[http] + async fn search_chats(&self, req: SearchChatsReq) -> Result, String> { + let query = req.query.to_lowercase(); + let results: Vec = self + .chats + .values() + .filter(|chat| { + chat.counterparty.to_lowercase().contains(&query) + || chat + .messages + .iter() + .any(|m| m.content.to_lowercase().contains(&query)) + }) + .cloned() + .collect(); + + Ok(results) + } + + #[http] + async fn search_index(&self, req: SearchIndexReq) -> Result { + let results = self + .search_index + .search(&req.query, req.scope, req.limit); + Ok(SearchIndexRes { results }) + } + + // uncomment #[remote] for tests + // #[remote] + #[local] + #[http] + async fn crdt_group_state_vector( + &mut self, + req: CrdtGroupStateVectorReq, + ) -> Result { + if self.group_needs_bootstrap(&req.group_id) { + return Err(format!( + "Group {} is pending bootstrap and cannot serve CRDT requests", + req.group_id + )); + } + + self.require_hub_access(&req.group_id, &our().node) + .map_err(|err| format!("hub access denied: {}", err))?; + + let manager = self + .ensure_group_doc_manager(&req.group_id) + .map_err(|e| format!("Failed to init group CRDT: {:?}", e))?; + + let (doc_id, state_vector) = { + let doc = manager.doc(); + (doc.id().to_string(), doc.state_vector()) + }; + log_crdt_event(&doc_id, "crdt_group_state_vector", &state_vector, None); + let encoded = base64_encode(&state_vector.encode_v1()); + manager.set_last_state_vector(state_vector); + + Ok(CrdtStateVectorRes { + state_vector: encoded, + }) + } + + #[remote] + #[local] + #[http] + async fn crdt_group_update( + &mut self, + req: CrdtGroupUpdateReq, + ) -> Result { + if self.group_needs_bootstrap(&req.group_id) { + return Err(format!( + "Group {} is pending bootstrap and cannot serve CRDT requests", + req.group_id + )); + } + + // V2.2: Validate sender is an active member for remote requests + // Only check if the group exists - if group doesn't exist, let it fail naturally later + let sender_addr = source(); + let sender_node = sender_addr.node.clone(); + if sender_node != our().node && self.groups.contains_key(&req.group_id) { + // Remote request - validate sender is an active member + self.require_subscriber_access(&req.group_id, &sender_node) + .map_err(|e| { + format!( + "CRDT update denied: sender {} not authorized: {}", + sender_node, e + ) + })?; + } + + self.require_hub_access(&req.group_id, &our().node) + .map_err(|err| format!("hub access denied: {}", err))?; + + let manager = self + .ensure_group_doc_manager(&req.group_id) + .map_err(|e| format!("Failed to init group CRDT: {:?}", e))?; + log_debug!( + "[CRDT][{}] crdt_group_update: state_vector={:?} doc_id={} manager_ptr={:p}", + req.group_id, + req.state_vector, + manager.doc().id(), + manager.doc() + ); + + if let Ok(state) = manager.doc().read_state() { + log_group_state_summary(manager.doc().id(), "crdt_group_update:sender_state", &state); + log_debug!( + "[CRDT][{}] sender_state members={:?}", + manager.doc().id(), + state + .group + .members + .iter() + .map(|(k, v)| (k, (&v.role_id, v.status))) + .collect::>() + ); + } + + let state_vector = + if let Some(encoded_sv) = req.state_vector.as_ref().filter(|s| !s.trim().is_empty()) { + let trimmed = encoded_sv.trim(); + let bytes = base64_decode(trimmed) + .map_err(|e| format!("Invalid state vector payload: {e}"))?; + Some( + StateVector::decode_v1(&bytes) + .map_err(|e| format!("Invalid state vector bytes: {:?}", e))?, + ) + } else { + None + }; + + let (doc_id, doc_vector, update_bytes) = { + let doc = manager.doc(); + ( + doc.id().to_string(), + doc.state_vector(), + doc.encode_update_since(state_vector.as_ref()), + ) + }; + log_crdt_event( + &doc_id, + "crdt_group_update", + &doc_vector, + Some(update_bytes.len()), + ); + + let update_payload = base64_encode(&update_bytes); + self.publish_group_delta(&req.group_id, &update_payload); + + Ok(CrdtUpdateRes { + doc_id, + update_payload, + }) + } + + #[remote] + #[local] + #[http] + async fn crdt_group_apply_update( + &mut self, + req: CrdtGroupApplyReq, + ) -> Result { + let group_id = req.group_id.clone(); + + // V2.2: Validate sender is an active member for remote requests + // Only check if the group exists - if group doesn't exist, let it fail naturally later + let sender_addr = source(); + let sender_node = sender_addr.node.clone(); + if sender_node != our().node && self.groups.contains_key(&group_id) { + // Remote request - validate sender is an active member + self.require_subscriber_access(&group_id, &sender_node) + .map_err(|e| { + format!( + "CRDT apply denied: sender {} not authorized: {}", + sender_node, e + ) + })?; + } + + self.apply_group_update_payload( + &group_id, + &req.update_payload, + "crdt_group_apply_update", + req.acl_version, + false, + )?; + Ok(CrdtApplyRes { applied: true }) + } + + #[remote] + #[local] + #[http] + async fn crdt_group_snapshot( + &mut self, + req: CrdtGroupSnapshotReq, + ) -> Result { + if self.group_needs_bootstrap(&req.group_id) { + return Err(format!( + "Group {} is pending bootstrap and cannot serve CRDT requests", + req.group_id + )); + } + + // V2.2: Validate sender is an active member for remote requests + // Only check if the group exists - if group doesn't exist, let it fail naturally later + let sender_addr = source(); + let sender_node = sender_addr.node.clone(); + if sender_node != our().node && self.groups.contains_key(&req.group_id) { + // Remote request - validate sender is an active member + self.require_subscriber_access(&req.group_id, &sender_node) + .map_err(|e| { + format!( + "CRDT snapshot denied: sender {} not authorized: {}", + sender_node, e + ) + })?; + } + + self.require_hub_access(&req.group_id, &our().node) + .map_err(|err| format!("hub access denied: {}", err))?; + + let manager = self + .ensure_group_doc_manager(&req.group_id) + .map_err(|e| format!("Failed to init group CRDT: {:?}", e))?; + + if let Ok(state) = manager.doc().read_state() { + log_group_state_summary( + manager.doc().id(), + "crdt_group_snapshot:sender_state", + &state, + ); + } + + let (doc_id, state_vector, update_bytes) = { + let doc = manager.doc(); + ( + doc.id().to_string(), + doc.state_vector(), + doc.encode_update_since(None), + ) + }; + + log_crdt_event( + &doc_id, + "crdt_group_snapshot", + &state_vector, + Some(update_bytes.len()), + ); + + let update_payload = base64_encode(&update_bytes); + Ok(CrdtUpdateRes { + doc_id, + update_payload, + }) + } + + // uncomment #[remote] for tests + // #[remote] + #[local] + #[http] + async fn replication_work(&mut self) -> Result<(), String> { + self.run_replication_work_guarded().await + } + + /// Immediately push a snapshot to a peer (bypassing the debounced queue). + /// This is called when a member is invited to get them bootstrapped immediately. + #[local] + #[http] + async fn push_snapshot_to_peer(&mut self, req: PushSnapshotToPeerReq) -> Result<(), String> { + log_debug!( + "[REPL][{}] push_snapshot_to_peer invoked peer={}", + req.group_id, req.peer + ); + let task = ReplicationTask { + group_id: req.group_id, + peer: req.peer, + kind: ReplicationKind::PushSnapshot, + since: None, + attempt: 0, + not_before: ChatState::now_secs(), + }; + self.process_replication_task(task).await; + Ok(()) + } + + // uncomment #[remote] for tests + // #[remote] + #[local] + #[http] + async fn admin_replication_state( + &mut self, + req: AdminReplicationStateReq, + ) -> Result { + self.refresh_bootstrap_flags(); + log_debug!( + "[ADMIN] admin_replication_state invoked filter={:?} group_count={}", + req.group_id, + self.groups.len() + ); + let filter = req.group_id; + let now = ChatState::now_secs(); + let groups: Vec = self + .groups + .iter() + .filter(|(id, _)| filter.as_ref().map_or(true, |gid| gid == *id)) + .map(|(group_id, group)| { + let local_member_status = group.members.get(&our().node).map(|m| m.status); + log_debug!( + "[ADMIN][{}] pending_bootstrap={} local_member_status={:?} whitelist_version={:?} hub_topic={} sub_topic={}", + group_id, + self.group_needs_bootstrap(group_id), + local_member_status, + self.pubsub.whitelist(group_id).map(|w| w.version()), + group.routing.hub_topic, + group.routing.subscriber_topic, + ); + let sub_lag = group + .delivery + .subscriber_cursors + .get(&our().node) + .map(|c| now.saturating_sub(c.updated_at)); + let hub_lag = group + .delivery + .hub_cursors + .get(&our().node) + .map(|c| now.saturating_sub(c.updated_at)); + GroupReplicationState { + group_id: group_id.clone(), + pending_bootstrap: self.group_needs_bootstrap(group_id), + routing: group.routing.clone(), + hubs: group.hubs.active.iter().cloned().collect(), + subscribers: group.subscribers.entries.keys().cloned().collect(), + hub_cursors: group.delivery.hub_cursors.clone(), + subscriber_cursors: group.delivery.subscriber_cursors.clone(), + whitelist_version: self.pubsub.whitelist(group_id).map(|w| w.version()), + subscriber_lag_secs: sub_lag, + hub_lag_secs: hub_lag, + } + }) + .collect(); + + Ok(AdminReplicationStateRes { + metrics: self.replication_metrics.clone(), + groups, + }) + } + + // uncomment #[remote] for tests + // #[remote] + #[local] + #[http] + async fn admin_whitelist(&self, req: AdminWhitelistReq) -> Result { + log_debug!( + "[ADMIN] admin_whitelist invoked group_id={} has_whitelist={}", + req.group_id, + self.pubsub.whitelist(&req.group_id).is_some() + ); + let whitelist = self + .pubsub + .whitelist(&req.group_id) + .ok_or_else(|| "whitelist missing".to_string())?; + + let fmt_pattern = |pattern: &hyperware_pubsub_core::whitelist::TopicPattern| match pattern { + hyperware_pubsub_core::whitelist::TopicPattern::Exact(p) => { + format!("exact:{p}") + } + hyperware_pubsub_core::whitelist::TopicPattern::Prefix(p) => { + format!("prefix:{p}") + } + }; + + let entries = whitelist + .entries() + .iter() + .map(|(node, access)| { + let expires_at = access + .expires_at + .and_then(|ts| ts.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + WhitelistEntryDebug { + node: node.0.clone(), + publish: access.publish.iter().map(fmt_pattern).collect(), + subscribe: access.subscribe.iter().map(fmt_pattern).collect(), + audiences: access.audiences.iter().cloned().collect(), + features: access.features.iter().cloned().collect(), + expires_at, + } + }) + .collect(); + + Ok(AdminWhitelistRes { + group_id: req.group_id, + version: whitelist.version(), + entries, + }) + } + + // uncomment #[remote] for tests + // #[remote] + #[local] + #[http] + async fn admin_subscriber_events( + &mut self, + req: SubscriberEventsReq, + ) -> Result { + log_debug!( + "[ADMIN] admin_subscriber_events invoked clear={} take={:?} buffered={}", + req.clear, + req.take, + self.subscriber_events.len() + ); + let take = req.take.unwrap_or(50); + let events = if req.clear { + // Clearing should drop pending events and return an empty list to signal nothing remains. + self.subscriber_events.clear(); + Vec::new() + } else { + let len = self.subscriber_events.len(); + let start = len.saturating_sub(take); + self.subscriber_events.iter().skip(start).cloned().collect() + }; + Ok(SubscriberEventsRes { events }) + } + + // SPIDER INTEGRATION + + #[http] + async fn spider_connect(&mut self, force_new: Option) -> Result { + const SPIDER_PROCESS_ID: (&str, &str, &str) = ("spider", "spider", "sys"); + + let should_force = force_new.unwrap_or(false); + log_debug!("[SPIDER] spider_connect called, force_new={:?}, should_force={}", force_new, should_force); + log_debug!("[SPIDER] cached key exists: {}", self.spider_api_key.is_some()); + + if !should_force { + if let Some(existing) = self.spider_api_key.clone() { + log_debug!("[SPIDER] Validating cached key: {}...", &existing[..8.min(existing.len())]); + // Validate the cached key before returning it + if self.validate_spider_key(&existing).await { + log_debug!("[SPIDER] Cached key is valid, returning it"); + return Ok(SpiderConnectResult { + api_key: existing, + }); + } + log_debug!("[SPIDER] cached spider API key is invalid, creating new one"); + } + } + + // Always use a unique name to ensure Spider creates a fresh key + let key_name = format!("homepage-{}-{}", our().node.clone(), std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis()); + + log_debug!("[SPIDER] Creating new key with name: {}", key_name); + + let body = serde_json::json!({ + "CreateSpiderKey": { + "name": key_name, + "permissions": vec!["read", "write", "chat"], + "adminKey": "", + } + }); + log_debug!("[SPIDER] Sending CreateSpiderKey request to spider:spider:sys"); + let request = ProcessRequest::to(Address::new("our", SPIDER_PROCESS_ID)) + .body( + serde_json::to_vec(&body) + .map_err(|err| format!("failed to serialize spider key request: {err}"))?, + ) + .expects_response(5); + + let parsed: Result = hyperapp::send(request) + .await + .map_err(|err| { + log_debug!("[SPIDER] Failed to contact spider: {}", err); + format!("failed to contact spider: {err}") + })?; + + match parsed { + Ok(key) => { + log_debug!("[SPIDER] Successfully created key: {}...", &key.key[..8.min(key.key.len())]); + self.spider_api_key = Some(key.key.clone()); + Ok(SpiderConnectResult { api_key: key.key }) + } + Err(err) => { + log_debug!("[SPIDER] Spider refused to create key: {}", err); + Err(format!("spider refused to create key: {err}")) + } + } + } + + #[http] + async fn spider_status(&self) -> Result { + const SPIDER_PROCESS_ID: (&str, &str, &str) = ("spider", "spider", "sys"); + + log_debug!("[SPIDER] spider_status called"); + let ping_body = serde_json::json!({ "Ping": null }); + let request = ProcessRequest::to(Address::new("our", SPIDER_PROCESS_ID)) + .body( + serde_json::to_vec(&ping_body) + .map_err(|err| format!("failed to serialize ping: {err}"))?, + ) + .expects_response(2); + let ping_result = hyperapp::send::(request).await; + let available = ping_result.is_ok(); + log_debug!("[SPIDER] Ping result: {:?}, available: {}", ping_result, available); + + let status = SpiderStatusInfo { + connected: self.spider_api_key.is_some() && available, + has_api_key: self.spider_api_key.is_some(), + spider_available: available, + }; + log_debug!("[SPIDER] Returning status: connected={}, has_api_key={}, spider_available={}", + status.connected, status.has_api_key, status.spider_available); + Ok(status) + } + + #[http] + async fn spider_get_history(&self) -> Result { + Ok(SpiderHistory { + messages: self.spider_history.clone(), + }) + } + + #[http] + async fn spider_set_history(&mut self, request: SpiderSetHistoryReq) -> Result<(), String> { + self.spider_history = request.messages; + Ok(()) + } + + // WEBSOCKET HANDLERS + + #[ws] + fn websocket(&mut self, channel_id: u32, message_type: WsMessageType, blob: LazyLoadBlob) { + // We'll differentiate between public and private connections via authentication + match message_type { + WsMessageType::Close => { + log_debug!("[WS_DEBUG] WebSocket Close received for channel {}, ws_connections before: {:?}", channel_id, self.ws_connections.keys().collect::>()); + // Clean up connection + if let Some(node) = self.ws_connections.remove(&channel_id) { + // Broadcast status update + let status_msg = WsServerMessage::StatusUpdate { + node: node.clone(), + status: "offline".to_string(), + }; + self.broadcast_ws_message(&status_msg); + } + + // Clean up browser connections + self.browser_connections.retain(|_, &mut v| v != channel_id); + self.active_connections.remove(&channel_id); + } + WsMessageType::Text => { + // Parse and handle client message + if let Ok(payload) = String::from_utf8(blob.bytes.clone()) { + match serde_json::from_str::(&payload) { + Ok(msg) => { + log_debug!( + "WebSocket: Received message from channel {}: {:?}", + channel_id, msg + ); + // Initialize connection if not already present + if !self.ws_connections.contains_key(&channel_id) + && !self + .browser_connections + .values() + .any(|&ch| ch == channel_id) + { + log_debug!( + "[WS_DEBUG] New connection from channel {}, ws_connections before: {:?}", + channel_id, self.ws_connections.keys().collect::>() + ); + self.ws_connections.insert(channel_id, our().node.clone()); + log_debug!( + "[WS_DEBUG] After insert, ws_connections: {:?}", + self.ws_connections.keys().collect::>() + ); + + // Send all existing chats to the new connection + log_debug!( + "WebSocket: Sending {} chats to new connection", + self.chats.len() + ); + for chat in self.chats.values() { + log_debug!( + "WebSocket: Sending chat {} with {} messages", + chat.id, + chat.messages.len() + ); + let chat_update = WsServerMessage::ChatUpdate(chat.clone()); + self.push_ws_message(channel_id, &chat_update); + } + log_debug!( + "WebSocket: Initial chat sync complete for channel {}", + channel_id + ); + } + + // Check if this is a browser chat authentication + if let WsClientMessage::AuthWithKey { .. } = &msg { + self.handle_browser_message(channel_id, msg); + } else if self + .browser_connections + .values() + .any(|&ch| ch == channel_id) + { + // If already authenticated as browser + self.handle_browser_message(channel_id, msg); + } else { + // Node-to-node message + self.handle_client_message(channel_id, msg); + } + } + Err(e) => { + let error = WsServerMessage::Error { + message: format!("Invalid message format: {}", e), + }; + self.push_ws_message(channel_id, &error); + } + } + } + } + WsMessageType::Binary => { + // Handle binary messages if needed (e.g., for voice calls later) + log_debug!("Binary message received on channel {}", channel_id); + } + WsMessageType::Ping | WsMessageType::Pong => { + // Ignore ping/pong messages + } + } + } +} + +// Helper methods (outside hyperapp impl) +impl ChatState { + // GROUP OPERATIONS + fn join_group_link_internal( + &mut self, + key: String, + candidate: NodeId, + ) -> Result { + let join_key = self + .group_join_keys + .get(&key) + .ok_or_else(|| "Join link not found".to_string())?; + if join_key.is_revoked { + return Err("Join link revoked".to_string()); + } + + let group_id = join_key.group_id.clone(); + self.join_public_group(&group_id, candidate) + .map_err(|err| err.to_string())?; + Ok(JoinGroupLinkRes { group_id }) + } + + // MESSAGE OPERATIONS + + /// MessageStatus lifecycle: + /// - New outbound messages start as `Sending`, are marked `Sent` once staged locally and + /// broadcast to connected clients, and move to `Delivered` when an ack arrives. + /// - Counterparty delivery uses RPC with an offline queue; WebSocket delivery is used if the + /// counterparty is connected locally. + /// - Failures in RPC enqueue a retry via the delivery worker; persistent failure can be marked + /// as `Failed` by the delivery pipeline. + /// - Frontends may optimistically render temp IDs; the `MessageAck` emitted to the origin + /// channel contains the canonical message_id for dedupe/update. + fn stage_outgoing_message( + &mut self, + chat_id: &str, + mut message: ChatMessage, + origin_channel: Option, + ) -> (String, ChatMessage) { + self.assign_sequence_to_message(chat_id, &mut message); + + let chat_snapshot = { + let chat = self.get_or_create_chat(chat_id, message.timestamp, None, None); + chat.messages.push(message.clone()); + chat.last_activity = message.timestamp; + + if let Some(msg) = chat.messages.iter_mut().find(|m| m.id == message.id) { + msg.status = safe_update_message_status(&msg.status, MessageStatus::Sent); + } + + chat.clone() + }; + self.rebuild_chat_search(chat_id); + + let counterparty = chat_snapshot.counterparty.clone(); + let stored_message = chat_snapshot + .messages + .iter() + .find(|m| m.id == message.id) + .cloned() + .unwrap_or(message); + + self.broadcast_ws_message(&WsServerMessage::ChatUpdate(chat_snapshot)); + + if let Some(ch_id) = origin_channel { + self.push_ws_message( + ch_id, + &WsServerMessage::MessageAck { + message_id: stored_message.id.clone(), + }, + ); + } + + (counterparty, stored_message) + } + + fn dispatch_outgoing_message(&self, counterparty: String, message: ChatMessage) { + // Try fast-path WebSocket delivery if the counterparty is connected locally; otherwise + // fall back to RPC with offline queue retry. + if let Some((&ch_id, _)) = self + .ws_connections + .iter() + .find(|(_, node)| *node == &counterparty) + { + self.push_ws_message(ch_id, &WsServerMessage::NewMessage(message)); + } else { + ChatState::spawn_delivery_attempt( + counterparty, + message, + self.delivery_tx.clone(), + self.pending_deliveries.clone(), + ); + } + } + + fn send_message_internal( + &mut self, + chat_id: &str, + content: String, + reply_to: Option, + origin_channel: Option, + ) -> Result { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let message_id = format!("{}:{}", timestamp, rand::random::()); + + let message = ChatMessage { + id: message_id.clone(), + sender: our().node.clone(), + content, + timestamp, + sequence: None, + status: MessageStatus::Sending, + reply_to, + reactions: Vec::new(), + message_type: MessageType::Text, + file_info: None, + }; + + let (counterparty, stored_message) = + self.stage_outgoing_message(chat_id, message, origin_channel); + self.dispatch_outgoing_message(counterparty, stored_message.clone()); + + // Return the latest stored version (with sequence/status) if available. + Ok(self + .chats + .get(chat_id) + .and_then(|chat| { + chat.messages + .iter() + .find(|m| m.id == stored_message.id) + .cloned() + }) + .unwrap_or(stored_message)) + } + + fn spawn_delivery_attempt( + counterparty: String, + msg_to_send: ChatMessage, + delivery_tx: DeliveryTx, + pending_deliveries: Arc>>>, + ) { + let message_id_clone = msg_to_send.id.clone(); + spawn(async move { + let target = Address::from((counterparty.as_str(), OUR_PROCESS_ID)); + let msg_json = serde_json::to_value(&msg_to_send).unwrap(); + let msg_for_rpc: CUChatMessage = serde_json::from_value(msg_json).unwrap(); + match receive_message_remote_rpc(&target, msg_for_rpc).await { + Ok(_) => { + log_debug!( + "Message {} sent successfully to {}", + message_id_clone, counterparty + ); + // Counterparty will send ACK on success. + } + Err(_) => { + log_debug!( + "Failed to send message {} to {}, adding to delivery queue", + message_id_clone, counterparty + ); + ChatState::enqueue_delivery_message_inner( + &delivery_tx, + &pending_deliveries, + &counterparty, + msg_to_send, + ); + } + } + }); + } + + // REPLICATION HELPERS + + async fn run_replication_work_guarded(&mut self) -> Result<(), String> { + if self + .replication_work_inflight + .compare_exchange(false, true, AtomicOrdering::SeqCst, AtomicOrdering::SeqCst) + .is_err() + { + log_debug!("[REPL] replication_work already running, skipping wake"); + return Ok(()); + } + let res = self.replication_work_inner().await; + self.replication_work_inflight + .store(false, AtomicOrdering::SeqCst); + // If work was enqueued while we were inflight, the wake may have been + // dropped by the guard above. Re-wake only when there is ready work to + // avoid tight loops on backoff/future tasks. + let now = ChatState::now_secs(); + if self + .replication_queue + .iter() + .any(|task| task.not_before <= now) + { + log_debug!("[REPL] ready replication tasks remain, re-waking worker"); + self.wake_replication_worker(); + } + res + } + + async fn replication_work_inner(&mut self) -> Result<(), String> { + self.refresh_bootstrap_flags(); + let started = Instant::now(); + let time_budget = Duration::from_secs(12); + log_debug!( + "[REPL] replication_work invoked pending_bootstrap={:?} queue_len={} now={}", + self.groups_pending_bootstrap, + self.replication_queue.len(), + ChatState::now_secs() + ); + let now = ChatState::now_secs(); + let applied = self.consume_broker_topics(32); + if applied > 0 { + log_debug!("[REPL] applied {} broker messages", applied); + } + self.enqueue_bootstrap_pulls(now); + self.enqueue_stale_subscriber_replays(now); + + let mut processed = 0usize; + while processed < 6 { + if started.elapsed() >= time_budget { + log_debug!( + "[REPL] replication_work time budget exhausted after {} tasks (elapsed {}ms)", + processed, + started.elapsed().as_millis() + ); + break; + } + let Some(task) = self.next_ready_replication_task(now) else { + break; + }; + self.process_replication_task(task).await; + processed += 1; + } + + log_debug!( + "[REPL] replication processed {} (elapsed {}ms) pending_bootstrap={:?}", + processed, + started.elapsed().as_millis(), + self.groups_pending_bootstrap + ); + if started.elapsed() > Duration::from_secs(5) { + log_debug!( + "[REPL_DIAG] replication_work slow_call elapsed_ms={} queue_len_end={} pending_bootstrap_end={:?}", + started.elapsed().as_millis(), + self.replication_queue.len(), + self.groups_pending_bootstrap + ); + } else { + log_debug!( + "[REPL_DIAG] replication_work done elapsed_ms={} queue_len_end={} pending_bootstrap_end={:?}", + started.elapsed().as_millis(), + self.replication_queue.len(), + self.groups_pending_bootstrap + ); + } + Ok(()) + } + + async fn process_replication_task(&mut self, task: ReplicationTask) { + let now = ChatState::now_secs(); + match task.kind { + ReplicationKind::PushDelta | ReplicationKind::PushSnapshot => { + let is_hub = self + .groups + .get(&task.group_id) + .map(|g| g.hubs.active.contains(&task.peer)) + .unwrap_or(false); + + // Check if we're a removed member trying to push our final update. + // If so, skip the local ACL check - the remote will decide whether to accept. + let our_member_status = self + .groups + .get(&task.group_id) + .and_then(|g| g.members.get(&our().node)) + .map(|m| m.status); + let is_self_removed = our_member_status == Some(MembershipStatus::Removed); + + if !is_self_removed { + if is_hub { + if let Err(err) = self.require_hub_access(&task.group_id, &our().node) { + log_debug!( + "[REPL][{}] skip push to {} (local hub publish denied): {}", + task.group_id, task.peer, err + ); + self.replication_metrics.acl_skips = + self.replication_metrics.acl_skips.saturating_add(1); + return; + } + } else if let Err(err) = + self.require_subscriber_access(&task.group_id, &our().node) + { + log_debug!( + "[REPL][{}] skip push to subscriber {} (local publish denied): {}", + task.group_id, task.peer, err + ); + self.replication_metrics.acl_skips = + self.replication_metrics.acl_skips.saturating_add(1); + return; + } + } + + let sv_hint = task + .since + .as_ref() + .and_then(|b| StateVector::decode_v1(b).ok()) + .or_else(|| self.peer_state_vector(&task.group_id, &task.peer)); + let acl_version = self.pubsub.whitelist(&task.group_id).map(|w| w.version()); + + let manager = match self.ensure_group_doc_manager(&task.group_id) { + Ok(m) => m, + Err(err) => { + log_debug!( + "[REPL][{}] cannot load doc for {}: {:?}", + task.group_id, task.peer, err + ); + self.schedule_backoff(task, now); + return; + } + }; + let doc = manager.doc(); + let update_bytes = if let ReplicationKind::PushSnapshot = task.kind { + doc.encode_update_since(None) + } else { + doc.encode_update_since(sv_hint.as_ref()) + }; + let state_vector = doc.state_vector(); + if update_bytes.is_empty() { + self.update_peer_state_vector(&task.group_id, &task.peer, &state_vector); + self.update_delivery_cursor( + &task.group_id, + &task.peer, + is_hub, + if is_hub { + self.groups + .get(&task.group_id) + .map(|g| g.routing.hub_topic.clone()) + .unwrap_or_default() + } else { + self.groups + .get(&task.group_id) + .map(|g| g.routing.subscriber_topic.clone()) + .unwrap_or_default() + }, + None, + ); + return; + } + + let payload = base64_encode(&update_bytes); + let body = serde_json::to_vec(&serde_json::json!({ + "CrdtGroupApplyUpdate": { + "group_id": task.group_id, + "update_payload": payload, + "acl_version": acl_version, + } + })) + .unwrap_or_default(); + + let target = Address::from((task.peer.as_str(), OUR_PROCESS_ID)); + let req = Request::new() + .target(target.clone()) + .body(body) + .expects_response(REPL_RPC_TIMEOUT_SECS); + log_debug!( + "[REPL][{}] push kind={:?} peer={} target={:?}", + task.group_id, task.kind, task.peer, target + ); + let rpc_started = Instant::now(); + match send::(req).await { + Ok(val) => { + log_debug!( + "[REPL_DIAG][{}] push roundtrip_ms={} kind={:?} peer={}", + task.group_id, + rpc_started.elapsed().as_millis(), + task.kind, + task.peer + ); + let apply_res = val + .get("Ok") + .cloned() + .or_else(|| Some(val.clone())) + .and_then(|v| serde_json::from_value::(v).ok()); + if let Some(res) = apply_res { + if res.applied { + self.update_peer_state_vector( + &task.group_id, + &task.peer, + &state_vector, + ); + let queue_id = if is_hub { + self.groups + .get(&task.group_id) + .map(|g| g.routing.hub_topic.clone()) + .unwrap_or_default() + } else { + self.groups + .get(&task.group_id) + .map(|g| g.routing.subscriber_topic.clone()) + .unwrap_or_default() + }; + self.update_delivery_cursor( + &task.group_id, + &task.peer, + is_hub, + queue_id, + None, + ); + } else { + self.schedule_backoff(task, now); + } + } else { + log_debug!( + "[REPL][{}] failed to decode apply response from {}: {:?}", + task.group_id, task.peer, val + ); + self.schedule_backoff(task, now); + } + } + Err(AppSendError::SendError(send_err)) => { + log_debug!( + "[REPL][{}] push to {} send error: {:?} (kind={:?} target={:?})", + task.group_id, task.peer, send_err, task.kind, target + ); + log_debug!( + "[REPL_DIAG][{}] push send_err after_ms={} kind={:?} peer={}", + task.group_id, + rpc_started.elapsed().as_millis(), + task.kind, + task.peer + ); + self.schedule_backoff(task, now); + } + Err(AppSendError::BuildError(build_err)) => { + log_debug!( + "[REPL][{}] push to {} build error: {:?} (kind={:?} target={:?})", + task.group_id, task.peer, build_err, task.kind, target + ); + log_debug!( + "[REPL_DIAG][{}] push build_err after_ms={} kind={:?} peer={}", + task.group_id, + rpc_started.elapsed().as_millis(), + task.kind, + task.peer + ); + self.schedule_backoff(task, now); + } + } + } + ReplicationKind::PullSnapshot => { + if let Some(res) = self + .fetch_snapshot_from_peer(&task.group_id, &task.peer) + .await + { + if let Err(err) = self.apply_group_update_payload( + &task.group_id, + &res.update_payload, + "replication_pull_snapshot", + None, + false, + ) { + log_debug!( + "[REPL][{}] failed to apply snapshot from {}: {}", + task.group_id, task.peer, err + ); + self.schedule_backoff(task, now); + } else if self.local_group_acl_ready(&task.group_id) { + self.groups_pending_bootstrap.remove(&task.group_id); + } + } else { + self.schedule_backoff(task, now); + } + } + ReplicationKind::PullDelta => { + let sv = self + .group_doc_managers + .get(&task.group_id) + .and_then(|mgr| mgr.last_state_vector().cloned()); + if let Some(res) = self + .fetch_update_from_peer(&task.group_id, &task.peer, sv) + .await + { + if res.update_payload.is_empty() { + return; + } + if let Err(err) = self.apply_group_update_payload( + &task.group_id, + &res.update_payload, + "replication_pull_delta", + None, + false, + ) { + log_debug!( + "[REPL][{}] failed to apply delta from {}: {}", + task.group_id, task.peer, err + ); + self.schedule_backoff(task, now); + } + } else { + self.schedule_backoff(task, now); + } + } + } + } + + fn schedule_backoff(&mut self, mut task: ReplicationTask, now: u64) { + let delay = (1u64 << (task.attempt.min(6))) * 2; + task.attempt = task.attempt.saturating_add(1); + task.not_before = now + delay; + self.replication_metrics.retries = self.replication_metrics.retries.saturating_add(1); + log_debug!( + "[REPL][{}] backoff {:?} to {} (attempt {} delay={}s)", + task.group_id, task.kind, task.peer, task.attempt, delay + ); + self.enqueue_replication_task(task); + } + + fn enqueue_bootstrap_pulls(&mut self, now: u64) { + let pending: Vec = self + .groups + .keys() + .cloned() + .filter(|g| self.group_needs_bootstrap(g)) + .collect(); + if !pending.is_empty() { + log_debug!( + "[BOOT] enqueue_bootstrap_pulls pending_groups={:?}", + pending + ); + } + for group_id in pending { + let peers: Vec = self + .groups + .get(&group_id) + .map(|g| { + g.hubs + .active + .iter() + .filter(|p| *p != &our().node) + .cloned() + .collect() + }) + .unwrap_or_default(); + for peer in peers { + if self.has_replication_task(&group_id, &peer, ReplicationKind::PullSnapshot) { + continue; + } + self.enqueue_replication_task(ReplicationTask { + group_id: group_id.clone(), + peer, + kind: ReplicationKind::PullSnapshot, + since: None, + attempt: 0, + not_before: now, + }); + } + } + } + + async fn fetch_update_from_peer( + &self, + group_id: &GroupId, + peer: &str, + state_vector: Option, + ) -> Option { + let target = Address::from((peer, OUR_PROCESS_ID)); + let sv_encoded = state_vector + .as_ref() + .map(|sv| base64_encode(&sv.encode_v1())); + let body = serde_json::to_vec(&serde_json::json!({ + "CrdtGroupUpdate": { + "group_id": group_id, + "state_vector": sv_encoded, + } + })) + .ok()?; + let request = Request::new() + .target(target.clone()) + .body(body) + .expects_response(REPL_RPC_TIMEOUT_SECS); + log_debug!( + "[REPL][{}] fetch_update_from_peer peer={} target={}", + group_id, peer, target + ); + + let req_started = Instant::now(); + match send::(request).await { + Ok(val) => { + let res = val + .get("Ok") + .cloned() + .or_else(|| Some(val.clone())) + .and_then(|v| serde_json::from_value::(v).ok()); + if let Some(res) = res { + log_debug!( + "[REPL_DIAG][{}] fetch_update_from_peer ok peer={} elapsed_ms={}", + group_id, + peer, + req_started.elapsed().as_millis(), + ); + Some(res) + } else { + log_debug!( + "[REPL][{}] failed to decode delta from {} body={:?}", + group_id, peer, val + ); + None + } + } + Err(AppSendError::SendError(err)) => { + log_debug!( + "[REPL][{}] failed to fetch delta from {} send_err={:?}", + group_id, peer, err + ); + log_debug!( + "[REPL_DIAG][{}] fetch_update_from_peer err peer={} elapsed_ms={}", + group_id, + peer, + req_started.elapsed().as_millis() + ); + None + } + Err(AppSendError::BuildError(build_err)) => { + log_debug!( + "[REPL][{}] failed to build delta request to {}: {:?}", + group_id, peer, build_err + ); + None + } + } + } + + async fn fetch_snapshot_from_peer( + &self, + group_id: &GroupId, + peer: &str, + ) -> Option { + let target = Address::from((peer, OUR_PROCESS_ID)); + let body = serde_json::to_vec(&serde_json::json!({ + "CrdtGroupSnapshot": { "group_id": group_id } + })) + .ok()?; + let request = Request::new() + .target(target.clone()) + .body(body) + // Keep snapshot pulls within the replication_work RPC budget. + .expects_response(REPL_RPC_TIMEOUT_SECS); + log_debug!( + "[REPL][{}] fetch_snapshot_from_peer peer={} target={}", + group_id, peer, target + ); + + let req_started = Instant::now(); + match send::(request).await { + Ok(val) => { + let res = val + .get("Ok") + .cloned() + .or_else(|| Some(val.clone())) + .and_then(|v| serde_json::from_value::(v).ok()); + if let Some(res) = res { + log_debug!( + "[REPL_DIAG][{}] fetch_snapshot_from_peer ok peer={} elapsed_ms={}", + group_id, + peer, + req_started.elapsed().as_millis(), + ); + Some(res) + } else { + log_debug!( + "[REPL][{}] failed to decode snapshot from {} body={:?}", + group_id, peer, val + ); + None + } + } + Err(AppSendError::SendError(err)) => { + log_debug!( + "[REPL][{}] failed to fetch snapshot from {} send_err={:?}", + group_id, peer, err + ); + log_debug!( + "[REPL_DIAG][{}] fetch_snapshot_from_peer err peer={} elapsed_ms={}", + group_id, + peer, + req_started.elapsed().as_millis() + ); + None + } + Err(AppSendError::BuildError(build_err)) => { + log_debug!( + "[REPL][{}] failed to build snapshot request to {}: {:?}", + group_id, peer, build_err + ); + None + } + } + } + + fn apply_group_update_payload( + &mut self, + group_id: &GroupId, + update_payload: &str, + context: &str, + incoming_acl_version: Option, + is_subscriber_lane: bool, + ) -> Result<(), String> { + let local_has_access = self.local_group_acl_ready(group_id); + let local_member_status = self + .groups + .get(group_id) + .and_then(|g| g.members.get(&our().node).map(|m| m.status)); + log_debug!( + "[CRDT][{}] apply_group_update_payload: context={} len={} local_acl_ready={} pending_bootstrap={} is_sub_lane={} incoming_acl={:?} local_member_status={:?}", + group_id, + context, + update_payload.len(), + local_has_access, + self.group_needs_bootstrap(group_id), + is_subscriber_lane, + incoming_acl_version, + local_member_status + ); + if let Some(in_acl) = incoming_acl_version { + if let Some(local_wl) = self.pubsub.whitelist(group_id) { + let local_version = local_wl.version(); + if in_acl != local_version { + log_debug!( + "[CRDT][{}] ACL version drift: incoming={} local={}", + group_id, in_acl, local_version + ); + } + } + } + + let update_bytes = base64_decode(update_payload.trim()) + .map_err(|e| format!("Invalid update payload: {e}"))?; + if update_bytes.is_empty() { + log_debug!( + "[CRDT][{}] context={} received EMPTY update payload", + group_id, context + ); + } + let was_missing = !self.groups.contains_key(group_id); + if was_missing { + self.groups_pending_bootstrap.insert(group_id.clone()); + log_debug!( + "[BOOT] new group seen via {} -> added to pending_bootstrap set", + context + ); + } + + if update_bytes.is_empty() && (was_missing || self.group_needs_bootstrap(group_id)) { + log_debug!( + "[CRDT][{}] context={} skipping empty update during bootstrap", + group_id, context + ); + return Ok(()); + } + + let mut enforce_acl = local_has_access && !self.group_needs_bootstrap(group_id); + if enforce_acl { + let local_status = self + .groups + .get(group_id) + .and_then(|g| g.members.get(&our().node).map(|m| m.status)); + if !matches!(local_status, Some(MembershipStatus::Active)) { + // Allow membership bootstrap/update to proceed even if we're not yet whitelisted. + log_debug!( + "[CRDT][{}] bypassing ACL for local_status={:?} context={}", + group_id, local_status, context + ); + enforce_acl = false; + } + } + if enforce_acl { + let routing = self + .groups + .get(group_id) + .map(|g| g.routing.clone()) + .unwrap_or_default(); + let routing_unavailable = + routing.hub_topic.is_empty() && routing.subscriber_topic.is_empty(); + if !routing_unavailable { + // Accept either hub subscription or subscriber subscription depending on lane + role. + let hub_ok = self.require_hub_subscription(group_id, &our().node); + if let Err(hub_err) = hub_ok { + let sub_ok = self.require_subscriber_access(group_id, &our().node); + if let Err(sub_err) = sub_ok { + // Only allow bypass when this update arrived via subscriber lane and we're not yet active. + let member_status = self + .groups + .get(group_id) + .and_then(|g| g.members.get(&our().node).map(|m| m.status)); + let is_new_or_pending = member_status.is_none() + || matches!(member_status, Some(MembershipStatus::Pending)); + if !(is_subscriber_lane && is_new_or_pending) { + log_debug!( + "[CRDT][{}] ACL reject context={} hub_err={} sub_err={} member_status={:?} is_sub_lane={}", + group_id, + context, + hub_err, + sub_err, + member_status, + is_subscriber_lane + ); + return Err(format!( + "hub subscription denied: {}; subscriber access denied: {}", + hub_err, sub_err + )); + } + } + } + } + } + + let manager = self + .ensure_group_doc_manager(group_id) + .map_err(|e| format!("Failed to init group CRDT: {:?}", e))?; + + let doc_id = manager.doc().id().to_string(); + log_debug!( + "[CRDT][{}] context={} incoming_update_bytes={}", + doc_id, + context, + update_bytes.len() + ); + + { + let doc = manager.doc(); + doc.apply_update(&update_bytes) + .map_err(|e| format!("Failed to apply update: {:?}", e))?; + } + + let group_state = { + let doc = manager.doc(); + doc.read_state() + .map_err(|e| format!("Failed to read CRDT state: {:?}", e))? + }; + log_group_state_summary(&doc_id, context, &group_state); + log_debug!( + "[CRDT][{}] context={} members_detail={:?}", + doc_id, + context, + group_state + .group + .members + .iter() + .map(|(k, v)| (k, (&v.role_id, v.status))) + .collect::>() + ); + log_debug!( + "[CRDT][{}] context={} applied_ok members={} hubs={} subs={}", + doc_id, + context, + group_state.group.members.len(), + group_state.group.hubs.active.len(), + group_state.group.subscribers.entries.len() + ); + + let new_vector = { + let doc = manager.doc(); + doc.state_vector() + }; + log_crdt_event(&doc_id, context, &new_vector, Some(update_bytes.len())); + manager.set_last_state_vector(new_vector.clone()); + self.update_local_hub_sync_state(group_id, &new_vector); + + // Capture old message IDs before applying update + let old_message_ids: std::collections::HashSet = self + .groups + .get(group_id) + .map(|g| g.messages.keys().cloned().collect()) + .unwrap_or_default(); + + group_state.apply_into(self); + self.rebuild_group_search(group_id); + + // Detect new messages and handle notifications/unread counts + if let Some(group) = self.groups.get(group_id) { + let group_name = group + .metadata + .as_ref() + .map(|m| m.name.clone()) + .unwrap_or_else(|| "Group".to_string()); + let our_node = our().node.clone(); + + // Find new messages from other users + let new_messages: Vec<_> = group + .messages + .values() + .filter(|m| !old_message_ids.contains(&m.message_id) && m.sender != our_node) + .collect(); + + if !new_messages.is_empty() { + // Increment unread count for this group + let unread_increment = new_messages.len() as u32; + *self.group_unread.entry(group_id.clone()).or_insert(0) += unread_increment; + + // Send push notification if conditions are met + let group_notify_enabled = self.group_notify.get(group_id).copied().unwrap_or(true); + let global_notify_enabled = self.settings.notify_groups; + let active_connection_count = self.active_connections.len(); + log_debug!( + "[NOTIFY] group_push_gate group_id={} group_notify={} global_notify={} active_connections={}", + group_id, + group_notify_enabled, + global_notify_enabled, + active_connection_count + ); + if global_notify_enabled + && group_notify_enabled + && active_connection_count == 0 + { + // Only notify for the most recent message to avoid spam + if let Some(latest) = new_messages.iter().max_by_key(|m| m.timestamp) { + let sender = latest.sender.clone(); + let content = latest.body.clone(); + let gid = group_id.clone(); + let gname = group_name.clone(); + spawn(async move { + send_push_notification_for_group_message(&sender, &content, &gid, &gname) + .await; + }); + } + } else { + log_debug!( + "[NOTIFY] group_push_skip group_id={} group_notify={} global_notify={} active_connections={}", + group_id, + group_notify_enabled, + global_notify_enabled, + active_connection_count + ); + } + } + } + + // Notify browser clients so they can refresh group state after receiving remote updates. + self.broadcast_ws_message(&WsServerMessage::GroupUpdate { + group_id: group_id.clone(), + }); + + // Consider bootstrap complete once the local node is an active member (or otherwise ACL-ready). + // Also mark complete if the local member has been removed - no point bootstrapping a group + // we've been kicked from. + let local_member = self + .groups + .get(group_id) + .and_then(|g| g.members.get(&our().node)); + let has_local_membership = local_member + .map(|m| m.status == MembershipStatus::Active) + .unwrap_or(false); + let is_removed = local_member + .map(|m| m.status == MembershipStatus::Removed) + .unwrap_or(false); + + let acl_ready = self.local_group_acl_ready(group_id); + log_debug!( + "[CRDT][{}] context={} post-apply has_local_membership={} is_removed={} acl_ready={} pending_bootstrap={}", + group_id, + context, + has_local_membership, + is_removed, + acl_ready, + self.group_needs_bootstrap(group_id) + ); + if acl_ready || has_local_membership || is_removed { + self.mark_group_bootstrapped(group_id); + } + Ok(()) + } +} + +// Helper methods implementation +impl ChatState { + fn infer_counterparty_from_chat_id(chat_id: &str, our_node: &str) -> String { + let mut parts = chat_id.splitn(2, ':'); + let first = parts.next().unwrap_or_default(); + let second = parts.next().unwrap_or_default(); + + if first == our_node { + second.to_string() + } else if second == our_node { + first.to_string() + } else { + // malformed ID; fall back to the tail for now + second.to_string() + } + } + + // Normalize chat ID to prevent duplicates + // Always returns the ID in alphabetical order: "nodeA:nodeB" + fn normalize_chat_id(node1: &str, node2: &str) -> String { + if node1 < node2 { + format!("{}:{}", node1, node2) + } else { + format!("{}:{}", node2, node1) + } + } + + fn get_or_create_chat<'a>( + &'a mut self, + chat_id: &str, + timestamp: u64, + counterparty_hint: Option, + profile_hint: Option, + ) -> &'a mut Chat { + if !self.chats.contains_key(chat_id) { + let counterparty = counterparty_hint + .or_else(|| Some(Self::infer_counterparty_from_chat_id(chat_id, &our().node))) + .unwrap_or_else(|| chat_id.to_string()); + + let profile = profile_hint.or_else(|| self.node_profiles.get(&counterparty).cloned()); + + self.chats.insert( + chat_id.to_string(), + Chat { + id: chat_id.to_string(), + counterparty: counterparty.clone(), + messages: Vec::new(), + last_activity: timestamp, + unread_count: 0, + is_blocked: false, + notify: true, + counterparty_profile: profile, + }, + ); + self.message_sequence_counters + .entry(chat_id.to_string()) + .or_insert(0); + } + + self.chats + .get_mut(chat_id) + .expect("chat must exist after get_or_create_chat") + } + + fn ensure_sequence_state(&mut self, chat_id: &str) { + if self.message_sequence_counters.contains_key(chat_id) { + return; + } + + if !self.chats.contains_key(chat_id) { + self.message_sequence_counters + .insert(chat_id.to_string(), 0); + return; + } + + let chat = self + .chats + .get_mut(chat_id) + .expect("chat must exist when ensuring sequences"); + + let mut next_seq = 0u64; + let mut indices: Vec = (0..chat.messages.len()).collect(); + indices.sort_by(|&i, &j| { + chat.messages[i] + .timestamp + .cmp(&chat.messages[j].timestamp) + .then_with(|| chat.messages[i].id.cmp(&chat.messages[j].id)) + }); + + let mut max_existing = chat + .messages + .iter() + .filter_map(|m| m.sequence) + .max() + .map(|val| val + 1); + + if max_existing.is_some() { + next_seq = max_existing.take().unwrap(); + } + + for idx in indices { + if chat.messages[idx].sequence.is_none() { + chat.messages[idx].sequence = Some(next_seq); + next_seq += 1; + } + } + + if next_seq == 0 { + next_seq = chat + .messages + .iter() + .filter_map(|m| m.sequence) + .max() + .map(|val| val + 1) + .unwrap_or(0); + } + + self.message_sequence_counters + .insert(chat_id.to_string(), next_seq); + } + + fn assign_sequence_to_message(&mut self, chat_id: &str, message: &mut ChatMessage) { + if message.sequence.is_some() { + return; + } + + self.ensure_sequence_state(chat_id); + + if let Some(counter) = self.message_sequence_counters.get_mut(chat_id) { + let seq = *counter; + *counter += 1; + message.sequence = Some(seq); + } + } + + fn enqueue_delivery_message(&self, node: &str, message: ChatMessage) { + ChatState::enqueue_delivery_message_inner( + &self.delivery_tx, + &self.pending_deliveries, + node, + message, + ); + } + + fn enqueue_delivery_flush(&self, node: &str) { + if let Err(err) = self + .delivery_tx + .unbounded_send(QueuedDelivery::flush(node.to_string())) + { + log_debug!("Failed to enqueue delivery flush for {}: {:?}", node, err); + } + } + + fn bootstrap_pending_deliveries(&self) { + for chat in self.chats.values() { + let counterparty = chat.counterparty.clone(); + for message in chat.messages.iter() { + if message.sender == our().node + && matches!( + message.status, + MessageStatus::Sent | MessageStatus::Sending | MessageStatus::Failed + ) + { + self.enqueue_delivery_message(&counterparty, message.clone()); + } + } + } + } + + fn enqueue_delivery_message_inner( + delivery_tx: &DeliveryTx, + pending_deliveries: &Arc>>>, + node: &str, + message: ChatMessage, + ) { + ChatState::record_pending_message(pending_deliveries, node, &message); + if let Err(err) = + delivery_tx.unbounded_send(QueuedDelivery::message(node.to_string(), message)) + { + log_debug!("Failed to enqueue delivery message for {}: {:?}", node, err); + } + } + + fn record_pending_message( + pending: &Arc>>>, + node: &str, + message: &ChatMessage, + ) { + let mut guard = pending.lock().unwrap(); + let entry = guard.entry(node.to_string()).or_default(); + if let Some(existing) = entry.iter_mut().find(|m| m.id == message.id) { + *existing = message.clone(); + } else { + entry.push(message.clone()); + } + } + + fn remove_pending_message( + pending: &Arc>>>, + node: &str, + message_id: &str, + ) { + let mut guard = pending.lock().unwrap(); + if let Some(entry) = guard.get_mut(node) { + entry.retain(|m| m.id != message_id); + if entry.is_empty() { + guard.remove(node); + } + } + } + + fn pending_messages_for_node( + pending: &Arc>>>, + node: &str, + ) -> Vec { + let guard = pending.lock().unwrap(); + guard.get(node).cloned().unwrap_or_default() + } + + fn is_message_pending( + pending: &Arc>>>, + node: &str, + message_id: &str, + ) -> bool { + let guard = pending.lock().unwrap(); + guard + .get(node) + .map(|messages| messages.iter().any(|m| m.id == message_id)) + .unwrap_or(false) + } + + async fn run_delivery_worker( + mut delivery_rx: UnboundedReceiver, + delivery_tx: DeliveryTx, + pending_deliveries: Arc>>>, + ) { + while let Some(queued) = delivery_rx.next().await { + match queued.event { + DeliveryEvent::Message(message) => { + ChatState::record_pending_message(&pending_deliveries, &queued.node, &message); + ChatState::attempt_delivery( + queued.node.clone(), + message, + delivery_tx.clone(), + pending_deliveries.clone(), + ) + .await; + } + DeliveryEvent::Flush => { + let messages = + ChatState::pending_messages_for_node(&pending_deliveries, &queued.node); + for msg in messages { + if let Err(err) = delivery_tx + .unbounded_send(QueuedDelivery::message(queued.node.clone(), msg)) + { + log_debug!( + "Failed to enqueue message during flush for {}: {:?}", + queued.node, err + ); + break; + } + } + } + } + } + } + + async fn attempt_delivery( + node: String, + message: ChatMessage, + delivery_tx: DeliveryTx, + pending_deliveries: Arc>>>, + ) { + let target = Address::from((node.as_str(), OUR_PROCESS_ID)); + + let msg_json = serde_json::to_value(&message).unwrap(); + let msg_for_rpc: CUChatMessage = serde_json::from_value(msg_json).unwrap(); + + match receive_message_remote_rpc(&target, msg_for_rpc).await { + Ok(_) => { + ChatState::remove_pending_message(&pending_deliveries, &node, &message.id); + } + Err(err) => { + log_debug!( + "Failed to deliver message {} to {}: {:?}", + message.id, node, err + ); + let retry_tx = delivery_tx.clone(); + let retry_pending = pending_deliveries.clone(); + let retry_node = node.clone(); + let retry_message = message.clone(); + spawn(async move { + let _ = sleep(30000).await; + if ChatState::is_message_pending(&retry_pending, &retry_node, &retry_message.id) + { + if let Err(send_err) = retry_tx.unbounded_send(QueuedDelivery::message( + retry_node.clone(), + retry_message, + )) { + log_debug!( + "Failed to requeue message {} for {}: {:?}", + message.id, retry_node, send_err + ); + } + } + }); + } + } + } +} + +// Helper functions for converting between UserProfile types +impl ChatState { + // Helper function to convert our UserProfile to chat_caller_utils::UserProfile + fn to_cu_user_profile(profile: &UserProfile) -> CUUserProfile { + CUUserProfile { + name: profile.name.clone(), + profile_pic: profile.profile_pic.clone(), + } + } + + /// Validates a Spider API key by making a lightweight test request + async fn validate_spider_key(&self, api_key: &str) -> bool { + const SPIDER_PROCESS_ID: (&str, &str, &str) = ("spider", "spider", "sys"); + + log_debug!("[SPIDER] validate_spider_key called for key: {}...", &api_key[..8.min(api_key.len())]); + + let body = serde_json::json!({ + "ListMcpServers": { + "authKey": api_key, + } + }); + + let request = ProcessRequest::to(Address::new("our", SPIDER_PROCESS_ID)) + .body(match serde_json::to_vec(&body) { + Ok(b) => b, + Err(e) => { + log_debug!("[SPIDER] Failed to serialize validation request: {}", e); + return false; + } + }) + .expects_response(5); + + let result: Result = hyperapp::send(request).await; + log_debug!("[SPIDER] Validation result: {:?}", result); + + match result { + Ok(json_body) => { + // Check if response is an error + if let Some(err) = json_body.get("Err") { + let err_str = err.as_str().unwrap_or(""); + let is_valid = !err_str.contains("Unauthorized") && !err_str.contains("Invalid API key"); + log_debug!("[SPIDER] Validation response has Err: {}, is_valid: {}", err_str, is_valid); + // If unauthorized or invalid key, return false + is_valid + } else { + log_debug!("[SPIDER] Validation successful (no Err in response)"); + true + } + } + Err(e) => { + log_debug!("[SPIDER] Validation request failed: {:?}", e); + false + } + } + } +} diff --git a/hyperdrive/packages/homepage/chat/src/logging.rs b/hyperdrive/packages/homepage/chat/src/logging.rs new file mode 100644 index 000000000..fa39c1263 --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/logging.rs @@ -0,0 +1,18 @@ +//! Simple logging utilities with compile-time verbosity control. +//! +//! Change VERBOSE to true/false before compiling to enable/disable debug logs. +//! +//! Use `log_debug!` for diagnostic messages that should only appear when verbose mode is on. + +/// Set to `true` to enable verbose debug logging, `false` to suppress. +pub const VERBOSE: bool = true; + +/// Log a debug message (only when VERBOSE is true). +#[macro_export] +macro_rules! log_debug { + ($($arg:tt)*) => { + if $crate::logging::VERBOSE && cfg!(not(test)) { + hyperware_process_lib::println!($($arg)*); + } + }; +} diff --git a/hyperdrive/packages/homepage/chat/src/pubsub.rs b/hyperdrive/packages/homepage/chat/src/pubsub.rs new file mode 100644 index 000000000..5329959cf --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/pubsub.rs @@ -0,0 +1,295 @@ +use std::collections::{HashMap, HashSet}; +use std::iter::FromIterator; + +use hyperware_pubsub_core::whitelist::{ + NodeAccess, NodeId as BrokerNodeId, TopicPattern, Whitelist, +}; + +use crate::crdt::{Group, GroupId, GroupRoutingConfig, GroupTier, MembershipStatus, Role}; + +const AUDIENCE_HUB: &str = "hub"; +const AUDIENCE_SUBSCRIBER: &str = "subscriber"; +const FEATURE_CRDT: &str = "crdt"; +const FEATURE_NOTIFY: &str = "notify"; + +#[derive(Default)] +pub struct PubSubRegistry { + groups: HashMap, +} + +#[derive(Clone)] +pub struct GroupBroker { + pub whitelist: Whitelist, + pub routing: GroupRoutingConfig, +} + +impl PubSubRegistry { + pub fn new() -> Self { + Self { + groups: HashMap::new(), + } + } + + pub fn rebuild_all(&mut self, groups: &HashMap) { + self.groups.clear(); + for (group_id, group) in groups { + self.rebuild_group(group_id, group); + } + } + + pub fn rebuild_group(&mut self, group_id: &GroupId, group: &Group) { + let routing = normalised_routing(group_id, group); + let whitelist = build_whitelist(group, &routing); + self.groups + .insert(group_id.clone(), GroupBroker { whitelist, routing }); + } + + pub fn remove_group(&mut self, group_id: &GroupId) { + self.groups.remove(group_id); + } + + pub fn whitelist(&self, group_id: &GroupId) -> Option<&Whitelist> { + self.groups.get(group_id).map(|broker| &broker.whitelist) + } + + pub fn routing(&self, group_id: &GroupId) -> Option<&GroupRoutingConfig> { + self.groups.get(group_id).map(|broker| &broker.routing) + } +} + +fn normalised_routing(group_id: &GroupId, group: &Group) -> GroupRoutingConfig { + if group.routing.hub_topic.is_empty() || group.routing.subscriber_topic.is_empty() { + GroupRoutingConfig::for_group(group_id) + } else { + group.routing.clone() + } +} + +fn build_whitelist(group: &Group, routing: &GroupRoutingConfig) -> Whitelist { + let mut whitelist = Whitelist::new(); + for (node_id, member) in &group.members { + if member.status != MembershipStatus::Active { + continue; + } + if let Some(role) = group.roles.get(&member.role_id) { + if let Some(access) = build_access_for_role(role, routing) { + whitelist.grant(BrokerNodeId::new(node_id.clone()), access); + } + } + } + + for node_id in group.subscribers.entries.keys() { + // Only grant subscriber access if the node is an active member + let is_active_member = group + .members + .get(node_id) + .map(|m| m.status == MembershipStatus::Active) + .unwrap_or(false); + if !is_active_member { + continue; + } + + let mut access = NodeAccess { + publish: Vec::new(), + subscribe: Vec::new(), + audiences: HashSet::from_iter([AUDIENCE_SUBSCRIBER.to_string()]), + features: HashSet::from_iter([FEATURE_NOTIFY.to_string()]), + expires_at: None, + }; + if !routing.subscriber_topic.is_empty() { + access + .subscribe + .push(TopicPattern::Exact(routing.subscriber_topic.clone())); + } + whitelist.grant(BrokerNodeId::new(node_id.clone()), access); + } + + whitelist +} + +fn build_access_for_role(role: &Role, routing: &GroupRoutingConfig) -> Option { + let mut publish = Vec::new(); + let mut subscribe = Vec::new(); + let mut audiences = HashSet::new(); + let mut features = HashSet::new(); + + match role.tier { + GroupTier::Hub => { + if !routing.hub_topic.is_empty() { + publish.push(TopicPattern::Exact(routing.hub_topic.clone())); + subscribe.push(TopicPattern::Exact(routing.hub_topic.clone())); + } + if !routing.subscriber_topic.is_empty() { + subscribe.push(TopicPattern::Exact(routing.subscriber_topic.clone())); + } + audiences.insert(AUDIENCE_HUB.to_string()); + features.insert(FEATURE_CRDT.to_string()); + } + GroupTier::Subscriber => { + if !routing.subscriber_topic.is_empty() { + subscribe.push(TopicPattern::Exact(routing.subscriber_topic.clone())); + } + audiences.insert(AUDIENCE_SUBSCRIBER.to_string()); + features.insert(FEATURE_NOTIFY.to_string()); + } + } + + Some(NodeAccess { + publish, + subscribe, + audiences, + features, + expires_at: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crdt::{GroupMember, GroupPermissions, SubscriberSyncState}; + use hyperware_pubsub_core::{whitelist::NodeId as BrokerNodeId, TopicId as BrokerTopicId}; + use std::time::SystemTime; + + fn sample_group(group_id: &str) -> Group { + let mut group = Group::default(); + group.routing = GroupRoutingConfig::for_group(&group_id.to_string()); + + let mut hub_permissions = GroupPermissions::empty(); + hub_permissions.insert(GroupPermissions::SEND_MESSAGES); + hub_permissions.insert(GroupPermissions::CREATE_THREADS); + hub_permissions.insert(GroupPermissions::INVITE_MEMBERS); + hub_permissions.insert(GroupPermissions::MANAGE_ROLES); + + let mut member_permissions = GroupPermissions::empty(); + member_permissions.insert(GroupPermissions::SEND_MESSAGES); + member_permissions.insert(GroupPermissions::CREATE_THREADS); + + let hub_role_id = "role:hub".to_string(); + let member_role_id = "role:member".to_string(); + + group.roles.insert( + hub_role_id.clone(), + Role::new(hub_role_id.clone(), "Hub", hub_permissions, GroupTier::Hub), + ); + group.roles.insert( + member_role_id.clone(), + Role::new( + member_role_id.clone(), + "Member", + member_permissions, + GroupTier::Subscriber, + ), + ); + + group.members.insert( + "hub.node".into(), + GroupMember::new("hub.node", hub_role_id, MembershipStatus::Active, 0), + ); + group.members.insert( + "member.node".into(), + GroupMember::new( + "member.node", + member_role_id.clone(), + MembershipStatus::Active, + 0, + ), + ); + group + .subscribers + .entries + .insert("member.node".into(), SubscriberSyncState::default()); + group.hubs.active.insert("hub.node".into()); + + group + } + + #[test] + fn registry_projects_acl_for_hubs_and_subscribers() { + let group_id = "group:test".to_string(); + let group = sample_group(&group_id); + let mut registry = PubSubRegistry::new(); + registry.rebuild_group(&group_id, &group); + + let whitelist = registry.whitelist(&group_id).expect("whitelist exists"); + let hub_topic = BrokerTopicId::new(group.routing.hub_topic.clone()); + let subscriber_topic = BrokerTopicId::new(group.routing.subscriber_topic.clone()); + + let hub_node = BrokerNodeId::new("hub.node".to_string()); + let member_node = BrokerNodeId::new("member.node".to_string()); + + assert!(whitelist + .publish_scope(&hub_node, &hub_topic, SystemTime::now()) + .is_some()); + assert!(whitelist + .subscribe_scope(&hub_node, &hub_topic, SystemTime::now()) + .is_some()); + assert!(whitelist + .subscribe_scope(&member_node, &subscriber_topic, SystemTime::now()) + .is_some()); + } + + #[test] + fn revoked_members_lose_subscriber_access() { + let group_id = "group:test".to_string(); + let mut group = sample_group(&group_id); + let mut registry = PubSubRegistry::new(); + + registry.rebuild_group(&group_id, &group); + let subscriber_topic = BrokerTopicId::new(group.routing.subscriber_topic.clone()); + let member_node = BrokerNodeId::new("member.node".to_string()); + + assert!(registry + .whitelist(&group_id) + .expect("whitelist exists") + .subscribe_scope(&member_node, &subscriber_topic, SystemTime::now()) + .is_some()); + + if let Some(member) = group.members.get_mut("member.node") { + member.status = MembershipStatus::Removed; + } + group.subscribers.entries.remove("member.node"); + registry.rebuild_group(&group_id, &group); + + assert!(registry + .whitelist(&group_id) + .expect("whitelist exists after rebuild") + .subscribe_scope(&member_node, &subscriber_topic, SystemTime::now()) + .is_none()); + } + + /// Tests that even if subscribers.entries is NOT cleaned up (race condition), + /// a removed member still loses access because we check membership status. + #[test] + fn removed_member_loses_access_even_with_stale_subscriber_entry() { + let group_id = "group:test".to_string(); + let mut group = sample_group(&group_id); + let mut registry = PubSubRegistry::new(); + + registry.rebuild_group(&group_id, &group); + let subscriber_topic = BrokerTopicId::new(group.routing.subscriber_topic.clone()); + let member_node = BrokerNodeId::new("member.node".to_string()); + + // Initially, member has access + assert!(registry + .whitelist(&group_id) + .expect("whitelist exists") + .subscribe_scope(&member_node, &subscriber_topic, SystemTime::now()) + .is_some()); + + // Mark member as Removed, but DON'T clean up subscribers.entries + // This simulates a race condition or bug where subscriber entry isn't cleaned + if let Some(member) = group.members.get_mut("member.node") { + member.status = MembershipStatus::Removed; + } + // Note: we deliberately do NOT call group.subscribers.entries.remove() + + registry.rebuild_group(&group_id, &group); + + // Member should still lose access because we check membership status + assert!(registry + .whitelist(&group_id) + .expect("whitelist exists after rebuild") + .subscribe_scope(&member_node, &subscriber_topic, SystemTime::now()) + .is_none()); + } +} diff --git a/hyperdrive/packages/homepage/chat/src/replication.rs b/hyperdrive/packages/homepage/chat/src/replication.rs new file mode 100644 index 000000000..c19809ed6 --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/replication.rs @@ -0,0 +1,655 @@ +use crate::{ + crdt::{ + DeliveryCursor, Group, GroupId, GroupTier, HubSyncState, MembershipStatus, + SubscriberSyncState, + }, + BrokerEnvelope, ChatState, ReplicationKind, ReplicationTask, SubscriberDeliveryEvent, +}; +use hyperware_crdt::yrs::{Decode, Encode, StateVector}; +use hyperware_process_lib::our; +use std::collections::VecDeque; +use std::hash::{Hash, Hasher}; + +// Replication-specific constants +const SUBSCRIBER_LANE_TTL_SECS: u64 = 300; +const SUBSCRIBER_ACK_DEADLINE_SECS: u64 = 45; +const DELIVERY_DEDUPE_WINDOW_SECS: u64 = 120; +const DELIVERY_DEDUPE_LIMIT: usize = 2048; +const SUBSCRIBER_EVENT_BUFFER: usize = 256; + +impl ChatState { + fn dedupe_key(topic: &str, payload: &str) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + topic.hash(&mut hasher); + payload.hash(&mut hasher); + hasher.finish() + } + + fn register_delivery_fingerprint(&mut self, topic: &str, payload: &str, now: u64) -> bool { + let key = Self::dedupe_key(topic, payload); + if let Some(ts) = self.delivery_dedupe.get(&key) { + if now.saturating_sub(*ts) < DELIVERY_DEDUPE_WINDOW_SECS { + return true; + } + } + self.delivery_dedupe.insert(key, now); + self.prune_dedupe_cache(now); + false + } + + fn prune_dedupe_cache(&mut self, now: u64) { + let cutoff = now.saturating_sub(DELIVERY_DEDUPE_WINDOW_SECS); + self.delivery_dedupe.retain(|_, ts| *ts >= cutoff); + if self.delivery_dedupe.len() > DELIVERY_DEDUPE_LIMIT { + let overflow = self + .delivery_dedupe + .len() + .saturating_sub(DELIVERY_DEDUPE_LIMIT); + if overflow == 0 { + return; + } + let mut oldest: Vec<(u64, u64)> = self + .delivery_dedupe + .iter() + .map(|(k, ts)| (*k, *ts)) + .collect(); + oldest.sort_by_key(|(_, ts)| *ts); + for (key, _) in oldest.into_iter().take(overflow) { + self.delivery_dedupe.remove(&key); + } + } + } + + fn record_subscriber_event(&mut self, event: SubscriberDeliveryEvent) { + self.subscriber_events.push_back(event); + if self.subscriber_events.len() > SUBSCRIBER_EVENT_BUFFER { + let overflow = self.subscriber_events.len() - SUBSCRIBER_EVENT_BUFFER; + for _ in 0..overflow { + self.subscriber_events.pop_front(); + } + } + } + + pub(crate) fn enqueue_replication_task(&mut self, task: ReplicationTask) { + self.replication_queue.push_back(task); + self.wake_replication_worker(); + } + + pub fn wake_replication_worker(&self) { + if let Some(tx) = &self.replication_wake_tx { + tx.wake(); + } + } + + pub(crate) fn next_ready_replication_task(&mut self, now: u64) -> Option { + let mut rotate = 0usize; + while let Some(task) = self.replication_queue.pop_front() { + if task.not_before <= now { + return Some(task); + } + self.replication_queue.push_back(task); + rotate += 1; + if rotate >= self.replication_queue.len() { + break; + } + } + None + } + + pub(crate) fn has_replication_task( + &self, + group_id: &GroupId, + peer: &str, + kind: ReplicationKind, + ) -> bool { + self.replication_queue.iter().any(|t| { + &t.group_id == group_id + && t.peer == peer + && std::mem::discriminant(&t.kind) == std::mem::discriminant(&kind) + }) + } + + pub(crate) fn peer_state_vector(&self, group_id: &GroupId, peer: &str) -> Option { + let group = self.groups.get(group_id)?; + let sync = group.hubs.sync.get(peer)?; + let bytes = sync.last_state_vector.as_ref()?; + StateVector::decode_v1(bytes).ok() + } + + pub(crate) fn update_peer_state_vector( + &mut self, + group_id: &GroupId, + peer: &str, + sv: &StateVector, + ) { + if let Some(group) = self.groups.get_mut(group_id) { + let now = Self::now_secs(); + group.hubs.upsert_sync( + peer.to_string(), + HubSyncState { + last_state_vector: Some(sv.encode_v1()), + last_seen_ts: now, + ..HubSyncState::default() + }, + ); + } + } + + pub(crate) fn update_local_hub_sync_state(&mut self, group_id: &GroupId, sv: &StateVector) { + if let Some(group) = self.groups.get_mut(group_id) { + let now = Self::now_secs(); + group.hubs.upsert_sync( + our().node.clone(), + HubSyncState { + last_state_vector: Some(sv.encode_v1()), + last_seen_ts: now, + ..HubSyncState::default() + }, + ); + } + } + + pub(crate) fn update_delivery_cursor( + &mut self, + group_id: &GroupId, + peer: &str, + is_hub: bool, + queue_id: String, + offset: Option, + ) { + if let Some(group) = self.groups.get_mut(group_id) { + let now = Self::now_secs(); + let cursors = if is_hub { + &mut group.delivery.hub_cursors + } else { + &mut group.delivery.subscriber_cursors + }; + let entry = cursors + .entry(peer.to_string()) + .or_insert_with(|| DeliveryCursor { + queue_id: queue_id.clone(), + last_offset: 0, + updated_at: now, + }); + entry.queue_id = queue_id; + let next = offset.unwrap_or_else(|| entry.last_offset.saturating_add(1)); + entry.last_offset = next; + entry.updated_at = now; + } + } + + pub(crate) fn enqueue_replication_pushes( + &mut self, + group_id: &GroupId, + state_vector_bytes: Vec, + ) { + let now = Self::now_secs(); + let Some(group) = self.groups.get(group_id).cloned() else { + return; + }; + // Hubs + for hub in &group.hubs.active { + if hub == &our().node { + continue; + } + let since = self + .peer_state_vector(group_id, hub) + .map(|sv| sv.encode_v1()); + let hub_age = group + .delivery + .hub_cursors + .get(hub) + .map(|c| now.saturating_sub(c.updated_at)) + .unwrap_or(u64::MAX); + let kind = if since.is_none() || hub_age > SUBSCRIBER_LANE_TTL_SECS { + ReplicationKind::PushSnapshot + } else { + ReplicationKind::PushDelta + }; + let task = ReplicationTask { + group_id: group_id.clone(), + peer: hub.clone(), + kind, + since, + attempt: 0, + not_before: now, + }; + self.enqueue_replication_task(task); + } + + // Subscribers - send to active members, plus removed members who haven't received + // their removal notification yet (need one final push so they know they were removed) + for (node_id, member) in &group.members { + if node_id == &our().node { + continue; + } + // Skip members who are pending (not yet fully joined) + if member.status == MembershipStatus::Pending { + continue; + } + // For removed members, only send if they haven't been notified yet + // Check if we've already sent them an update after their removal + if member.status == MembershipStatus::Removed { + let last_cursor_update = group + .delivery + .subscriber_cursors + .get(node_id) + .map(|c| c.updated_at) + .unwrap_or(0); + // If we've sent them an update after they were removed, skip them + // (member.last_activity is set to the removal timestamp) + if last_cursor_update >= member.last_activity { + continue; + } + } + // For active Hub members, skip (they're handled in the Hubs loop above) + let is_hub = group + .roles + .get(&member.role_id) + .map(|role| role.tier == GroupTier::Hub) + .unwrap_or(false); + if is_hub && member.status == MembershipStatus::Active { + continue; + } + let since = self + .peer_state_vector(group_id, node_id) + .map(|sv| sv.encode_v1()); + let cursor_age = group + .delivery + .subscriber_cursors + .get(node_id) + .map(|c| now.saturating_sub(c.updated_at)) + .unwrap_or(u64::MAX); + let kind = if since.is_none() || cursor_age > SUBSCRIBER_LANE_TTL_SECS { + ReplicationKind::PushSnapshot + } else { + ReplicationKind::PushDelta + }; + self.enqueue_replication_task(ReplicationTask { + group_id: group_id.clone(), + peer: node_id.clone(), + kind, + since, + attempt: 0, + not_before: now, + }); + } + + // Ensure we have our own sync recorded + if let Ok(sv) = StateVector::decode_v1(&state_vector_bytes) { + self.update_local_hub_sync_state(group_id, &sv); + } + } + + pub(crate) fn publish_broker_message( + &mut self, + topic: &str, + payload: &str, + acl_version: Option, + kind: ReplicationKind, + ) { + let next = *self.broker_offsets.get(topic).unwrap_or(&0); + let env = BrokerEnvelope { + offset: next, + payload: payload.to_string(), + acl_version, + kind, + ts: Self::now_secs(), + }; + let entry = self + .broker_queues + .entry(topic.to_string()) + .or_insert_with(VecDeque::new); + entry.push_back(env); + self.broker_offsets + .insert(topic.to_string(), next.saturating_add(1)); + crate::log_debug!( + "[BROKER] topic={} enqueued offset={} len={}", + topic, + next, + entry.len() + ); + self.wake_replication_worker(); + } + + pub(crate) fn consume_broker_topics(&mut self, max_per_topic: usize) -> usize { + let mut applied = 0usize; + let now = Self::now_secs(); + let topics: Vec = self + .groups + .values() + .flat_map(|g| { + let mut t = Vec::new(); + if g.hubs.active.contains(&our().node) && !g.routing.hub_topic.is_empty() { + t.push(g.routing.hub_topic.clone()); + } + // subscriber lane consumption if we are in subscribers + if g.subscribers.entries.contains_key(&our().node) + && !g.routing.subscriber_topic.is_empty() + { + t.push(g.routing.subscriber_topic.clone()); + } + t + }) + .collect(); + + for topic in topics { + let from = *self.broker_cursors.get(&topic).unwrap_or(&0); + let envelopes: Vec = self + .broker_queues + .get(&topic) + .map(|q| { + q.iter() + .filter(|e| e.offset >= from) + .take(max_per_topic) + .cloned() + .collect() + }) + .unwrap_or_default(); + + for env in envelopes { + if let Err(err) = self.apply_broker_envelope(&topic, &env, now) { + crate::log_debug!( + "[BROKER] topic={} offset={} apply error: {}", + topic, env.offset, err + ); + continue; + } + applied += 1; + self.broker_cursors.insert(topic.clone(), env.offset + 1); + } + } + applied + } + + #[cfg(feature = "test-helpers")] + pub fn enqueue_stale_subscriber_replays(&mut self, now: u64) { + self.enqueue_stale_subscriber_replays_inner(now); + } + + #[cfg(not(feature = "test-helpers"))] + pub(crate) fn enqueue_stale_subscriber_replays(&mut self, now: u64) { + self.enqueue_stale_subscriber_replays_inner(now); + } + + fn enqueue_stale_subscriber_replays_inner(&mut self, now: u64) { + let local_node = our().node.clone(); + self.enqueue_stale_subscriber_replays_for_node(now, &local_node); + } + + fn enqueue_stale_subscriber_replays_for_node(&mut self, now: u64, local_node: &str) { + let groups: Vec<(GroupId, Group)> = self + .groups + .iter() + .map(|(id, group)| (id.clone(), group.clone())) + .collect(); + for (group_id, group) in groups { + if group.routing.subscriber_topic.is_empty() { + continue; + } + for (node_id, _) in group.subscribers.entries.iter() { + if node_id == local_node { + continue; + } + if self.has_replication_task(&group_id, node_id, ReplicationKind::PushSnapshot) + || self.has_replication_task(&group_id, node_id, ReplicationKind::PushDelta) + { + continue; + } + // Check the correct cursor based on whether node is a hub or subscriber. + // Hubs use hub_cursors, subscribers use subscriber_cursors. + let is_hub = group.hubs.active.contains(node_id); + let cursor_age = if is_hub { + group.delivery.hub_cursors.get(node_id) + } else { + group.delivery.subscriber_cursors.get(node_id) + } + .map(|cursor| now.saturating_sub(cursor.updated_at)); + let stale = cursor_age + .map(|age| age > SUBSCRIBER_ACK_DEADLINE_SECS) + .unwrap_or(true); + if !stale { + continue; + } + let since = self + .peer_state_vector(&group_id, node_id) + .map(|sv| sv.encode_v1()); + let age = cursor_age.unwrap_or(0); + let kind = if since.is_none() || age > SUBSCRIBER_LANE_TTL_SECS { + ReplicationKind::PushSnapshot + } else { + ReplicationKind::PushDelta + }; + let kind_for_log = kind.clone(); + self.enqueue_replication_task(ReplicationTask { + group_id: group_id.clone(), + peer: node_id.clone(), + kind, + since, + attempt: 0, + not_before: now, + }); + self.replication_metrics.stale_replays = + self.replication_metrics.stale_replays.saturating_add(1); + crate::log_debug!( + "[REPL][{}] queued {} replay to {} {} (age={}s)", + group_id, + match kind_for_log { + ReplicationKind::PushSnapshot => "snapshot", + _ => "delta", + }, + if is_hub { "hub" } else { "subscriber" }, + node_id, + age + ); + } + } + } + + #[cfg(feature = "test-helpers")] + pub fn apply_broker_envelope( + &mut self, + topic: &str, + env: &BrokerEnvelope, + now: u64, + ) -> Result<(), String> { + self.apply_broker_envelope_inner(topic, env, now) + } + + #[cfg(not(feature = "test-helpers"))] + fn apply_broker_envelope( + &mut self, + topic: &str, + env: &BrokerEnvelope, + now: u64, + ) -> Result<(), String> { + self.apply_broker_envelope_inner(topic, env, now) + } + + fn apply_broker_envelope_inner( + &mut self, + topic: &str, + env: &BrokerEnvelope, + now: u64, + ) -> Result<(), String> { + // find group by topic + let group_id = self + .groups + .iter() + .find(|(_, g)| g.routing.hub_topic == topic || g.routing.subscriber_topic == topic) + .map(|(id, _)| id.clone()) + .ok_or_else(|| "no group for topic".to_string())?; + let is_subscriber_topic = self + .groups + .get(&group_id) + .map(|g| g.routing.subscriber_topic == topic) + .unwrap_or(false); + let created_at = if env.ts == 0 { now } else { env.ts }; + let age = now.saturating_sub(created_at); + if age > SUBSCRIBER_ACK_DEADLINE_SECS { + crate::log_debug!( + "[BROKER][{}] delivery lag {}s topic={} offset={}", + group_id, age, topic, env.offset + ); + } + if is_subscriber_topic { + self.replication_metrics.last_subscriber_lag_secs = age; + if age > SUBSCRIBER_LANE_TTL_SECS { + self.replication_metrics.drops = self.replication_metrics.drops.saturating_add(1); + crate::log_debug!( + "[BROKER][{}] drop stale subscriber envelope topic={} offset={} age={}s", + group_id, topic, env.offset, age + ); + return Ok(()); + } + if self.register_delivery_fingerprint(topic, &env.payload, now) { + self.replication_metrics.drops = self.replication_metrics.drops.saturating_add(1); + crate::log_debug!( + "[BROKER][{}] drop duplicate subscriber envelope topic={} offset={}", + group_id, topic, env.offset + ); + return Ok(()); + } + } else { + self.replication_metrics.last_lag_secs = age; + } + + // ACL drift log + if let Some(in_acl) = env.acl_version { + if let Some(wl) = self.pubsub.whitelist(&group_id) { + let local = wl.version(); + if local != in_acl { + crate::log_debug!( + "[BROKER][{}] ACL drift topic {} incoming={} local={}", + group_id, topic, in_acl, local + ); + self.replication_metrics.acl_drifts = + self.replication_metrics.acl_drifts.saturating_add(1); + } + } + } + + self.apply_group_update_payload( + &group_id, + &env.payload, + "broker_delivery", + env.acl_version, + is_subscriber_topic, + ) + .map_err(|err| { + self.replication_metrics.drops = self.replication_metrics.drops.saturating_add(1); + err + })?; + // update cursors/delivery trackers + let is_hub = self + .groups + .get(&group_id) + .map(|g| g.routing.hub_topic == topic) + .unwrap_or(false); + self.update_delivery_cursor( + &group_id, + &our().node, + is_hub, + topic.to_string(), + Some(env.offset), + ); + // bump heartbeat + if let Some(group) = self.groups.get_mut(&group_id) { + group.hubs.upsert_sync( + our().node.clone(), + HubSyncState { + last_seen_ts: now, + ..HubSyncState::default() + }, + ); + if is_subscriber_topic { + let subscriber = group + .subscribers + .entries + .entry(our().node.clone()) + .or_insert_with(SubscriberSyncState::default); + subscriber.last_seen_ts = now; + subscriber.last_state_vector = self + .group_doc_managers + .get(&group_id) + .and_then(|mgr| mgr.last_state_vector().map(|sv| sv.encode_v1())); + if let ReplicationKind::PushSnapshot = env.kind { + let digest = format!("{:x}", Self::dedupe_key(topic, &env.payload)); + subscriber.last_snapshot_digest = Some(digest); + } + } + } + if is_subscriber_topic { + self.record_subscriber_event(SubscriberDeliveryEvent { + group_id, + topic: topic.to_string(), + offset: env.offset, + kind: env.kind.clone(), + age_secs: age, + recorded_at: now, + }); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::SUBSCRIBER_ACK_DEADLINE_SECS; + use crate::crdt::{DeliveryCursor, Group, GroupRoutingConfig, SubscriberSyncState}; + use crate::ChatState; + + #[test] + fn enqueue_stale_replays_uses_hub_cursors_for_hubs() { + let mut state = ChatState::default(); + let group_id = "group:replay".to_string(); + let hub_node = "hub.node".to_string(); + let sub_node = "sub.node".to_string(); + + let mut group = Group::default(); + group.routing = GroupRoutingConfig::for_group(&group_id); + group + .subscribers + .entries + .insert(hub_node.clone(), SubscriberSyncState::default()); + group + .subscribers + .entries + .insert(sub_node.clone(), SubscriberSyncState::default()); + group.hubs.active.insert(hub_node.clone()); + + let now: u64 = 1_000; + group.delivery.hub_cursors.insert( + hub_node.clone(), + DeliveryCursor { + queue_id: "hub-queue".to_string(), + last_offset: 1, + updated_at: now.saturating_sub(SUBSCRIBER_ACK_DEADLINE_SECS - 1), + }, + ); + group.delivery.subscriber_cursors.insert( + hub_node.clone(), + DeliveryCursor { + queue_id: "sub-queue".to_string(), + last_offset: 1, + updated_at: now.saturating_sub(SUBSCRIBER_ACK_DEADLINE_SECS + 1), + }, + ); + group.delivery.subscriber_cursors.insert( + sub_node.clone(), + DeliveryCursor { + queue_id: "sub-queue".to_string(), + last_offset: 1, + updated_at: now.saturating_sub(SUBSCRIBER_ACK_DEADLINE_SECS + 1), + }, + ); + + state.groups.insert(group_id, group); + + state.enqueue_stale_subscriber_replays_for_node(now, "local.node"); + + let peers: Vec = state.replication_queue.iter().map(|t| t.peer.clone()).collect(); + assert!(peers.contains(&sub_node)); + assert!(!peers.contains(&hub_node)); + } +} diff --git a/hyperdrive/packages/homepage/chat/src/search.rs b/hyperdrive/packages/homepage/chat/src/search.rs new file mode 100644 index 000000000..1fea16a0b --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/search.rs @@ -0,0 +1,546 @@ +use std::collections::{HashMap, HashSet}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::crdt::{Group, GroupId, MembershipStatus}; +use crate::types::{Chat, ChatMessage}; +use crate::{SearchResultItem, SearchResultKind, SearchScope}; + +const DEFAULT_LIMIT: usize = 50; +const MAX_LIMIT: usize = 200; +const MAX_BODY_LEN: usize = 512; +const MAX_CHAT_MESSAGES_PER_CHAT: usize = 5000; +const MAX_GROUP_MESSAGES_PER_GROUP: usize = 5000; +const MESSAGE_WINDOW_SECS: u64 = 180 * 24 * 60 * 60; + +#[derive(Clone, Debug)] +struct SearchDoc { + id: String, + kind: SearchResultKind, + chat_id: Option, + group_id: Option, + message_id: Option, + thread_id: Option, + title: String, + body: String, + timestamp: Option, + tokens: HashSet, +} + +impl SearchDoc { + fn new( + id: String, + kind: SearchResultKind, + chat_id: Option, + group_id: Option, + message_id: Option, + thread_id: Option, + title: String, + body: String, + timestamp: Option, + ) -> Option { + let trimmed_title = title.trim(); + let trimmed_body = body.trim(); + let combined = if trimmed_body.is_empty() { + trimmed_title.to_string() + } else { + format!("{trimmed_title} {trimmed_body}") + }; + let tokens = tokenize(&combined); + if tokens.is_empty() { + return None; + } + + Some(Self { + id, + kind, + chat_id, + group_id, + message_id, + thread_id, + title: trimmed_title.to_string(), + body: trimmed_body.to_string(), + timestamp, + tokens, + }) + } +} + +#[derive(Default)] +pub(crate) struct SearchIndex { + docs: HashMap, + tokens: HashMap>, + chat_doc_ids: HashMap>, + group_doc_ids: HashMap>, +} + +impl SearchIndex { + pub fn clear(&mut self) { + self.docs.clear(); + self.tokens.clear(); + self.chat_doc_ids.clear(); + self.group_doc_ids.clear(); + } + + pub fn rebuild( + &mut self, + chats: &HashMap, + groups: &HashMap, + our_node: &str, + ) { + self.clear(); + for chat in chats.values() { + self.rebuild_chat(&chat.id, chat); + } + for (group_id, group) in groups { + self.rebuild_group(group_id, group, our_node); + } + } + + pub fn rebuild_chat(&mut self, chat_id: &str, chat: &Chat) { + self.remove_chat(chat_id); + let mut doc_ids = HashSet::new(); + + if let Some(summary) = build_chat_summary_doc(chat) { + doc_ids.insert(summary.id.clone()); + self.insert_doc(summary); + } + + let cutoff = now_secs().saturating_sub(MESSAGE_WINDOW_SECS); + let mut messages: Vec<&ChatMessage> = chat + .messages + .iter() + .filter(|m| m.timestamp >= cutoff) + .collect(); + messages.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + + for msg in messages.into_iter().take(MAX_CHAT_MESSAGES_PER_CHAT) { + if msg.content.trim().is_empty() { + continue; + } + if let Some(doc) = build_chat_message_doc(chat, msg) { + doc_ids.insert(doc.id.clone()); + self.insert_doc(doc); + } + } + + if !doc_ids.is_empty() { + self.chat_doc_ids.insert(chat_id.to_string(), doc_ids); + } + } + + pub fn rebuild_group(&mut self, group_id: &GroupId, group: &Group, our_node: &str) { + self.remove_group(group_id); + let is_active = group + .members + .get(our_node) + .map(|m| m.status == MembershipStatus::Active) + .unwrap_or(false); + if !is_active { + return; + } + + let mut doc_ids = HashSet::new(); + if let Some(summary) = build_group_summary_doc(group_id, group) { + doc_ids.insert(summary.id.clone()); + self.insert_doc(summary); + } + + let cutoff = now_secs().saturating_sub(MESSAGE_WINDOW_SECS); + let mut messages: Vec<_> = group + .messages + .values() + .filter(|m| m.timestamp >= cutoff && !m.body.trim().is_empty()) + .collect(); + messages.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + + for msg in messages.into_iter().take(MAX_GROUP_MESSAGES_PER_GROUP) { + if let Some(doc) = build_group_message_doc(group_id, group, msg) { + doc_ids.insert(doc.id.clone()); + self.insert_doc(doc); + } + } + + if !doc_ids.is_empty() { + self.group_doc_ids + .insert(group_id.to_string(), doc_ids); + } + } + + pub fn remove_chat(&mut self, chat_id: &str) { + if let Some(ids) = self.chat_doc_ids.remove(chat_id) { + for id in ids { + self.remove_doc(&id); + } + } + } + + pub fn remove_group(&mut self, group_id: &GroupId) { + if let Some(ids) = self.group_doc_ids.remove(group_id) { + for id in ids { + self.remove_doc(&id); + } + } + } + + pub fn search( + &self, + query: &str, + scope: SearchScope, + limit: Option, + ) -> Vec { + let clauses: Vec<&str> = query + .split('|') + .map(str::trim) + .filter(|clause| !clause.is_empty()) + .collect(); + if clauses.is_empty() { + return Vec::new(); + } + + let mut all_tokens: HashSet = HashSet::new(); + let mut score_by_doc: HashMap = HashMap::new(); + + for clause in clauses { + let clause_tokens = tokenize_query(clause); + if clause_tokens.is_empty() { + continue; + } + for token in &clause_tokens { + all_tokens.insert(token.clone()); + } + + let mut candidates: Option> = None; + for token in &clause_tokens { + let docs_for_token = self.docs_for_token(token); + if docs_for_token.is_empty() { + candidates = Some(HashSet::new()); + break; + } + candidates = Some(match candidates { + None => docs_for_token, + Some(prev) => prev.intersection(&docs_for_token).cloned().collect(), + }); + } + + let Some(candidate_ids) = candidates else { + continue; + }; + if candidate_ids.is_empty() { + continue; + } + + for doc_id in candidate_ids { + let Some(doc) = self.docs.get(&doc_id) else { + continue; + }; + if !doc_matches_scope(doc.kind, scope) { + continue; + } + let score = score_doc(doc, &clause_tokens); + if score == 0 { + continue; + } + let entry = score_by_doc.entry(doc_id).or_insert(score); + if score > *entry { + *entry = score; + } + } + } + + let query_tokens: Vec = all_tokens.into_iter().collect(); + let mut scored: Vec<(i64, u64, &SearchDoc)> = Vec::new(); + for (doc_id, score) in score_by_doc { + if let Some(doc) = self.docs.get(&doc_id) { + let ts = doc.timestamp.unwrap_or(0); + scored.push((score, ts, doc)); + } + } + + scored.sort_by(|a, b| { + b.0.cmp(&a.0) + .then_with(|| b.1.cmp(&a.1)) + .then_with(|| a.2.title.cmp(&b.2.title)) + }); + + let capped = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + scored + .into_iter() + .take(capped) + .map(|(_, _, doc)| doc_to_result(doc, &query_tokens)) + .collect() + } + + fn insert_doc(&mut self, doc: SearchDoc) { + let doc_id = doc.id.clone(); + for token in &doc.tokens { + self.tokens + .entry(token.clone()) + .or_default() + .insert(doc_id.clone()); + } + self.docs.insert(doc_id, doc); + } + + fn remove_doc(&mut self, doc_id: &str) { + if let Some(doc) = self.docs.remove(doc_id) { + for token in doc.tokens { + if let Some(ids) = self.tokens.get_mut(&token) { + ids.remove(doc_id); + if ids.is_empty() { + self.tokens.remove(&token); + } + } + } + } + } + + fn docs_for_token(&self, token: &str) -> HashSet { + let mut out = HashSet::new(); + if let Some(ids) = self.tokens.get(token) { + out.extend(ids.iter().cloned()); + } + for (indexed, ids) in &self.tokens { + if indexed.starts_with(token) { + out.extend(ids.iter().cloned()); + } + } + out + } +} + +fn build_chat_summary_doc(chat: &Chat) -> Option { + let last_message = chat.messages.last().map(|msg| msg.content.clone()).unwrap_or_default(); + SearchDoc::new( + format!("chat:{}:summary", chat.id), + SearchResultKind::ChatSummary, + Some(chat.id.clone()), + None, + None, + None, + chat.counterparty.clone(), + truncate_text(&last_message, MAX_BODY_LEN), + Some(chat.last_activity), + ) +} + +fn build_chat_message_doc(chat: &Chat, msg: &ChatMessage) -> Option { + SearchDoc::new( + format!("chat:{}:msg:{}", chat.id, msg.id), + SearchResultKind::ChatMessage, + Some(chat.id.clone()), + None, + Some(msg.id.clone()), + None, + chat.counterparty.clone(), + truncate_text(&msg.content, MAX_BODY_LEN), + Some(msg.timestamp), + ) +} + +fn build_group_summary_doc(group_id: &GroupId, group: &Group) -> Option { + let name = group + .metadata + .as_ref() + .map(|meta| meta.name.clone()) + .unwrap_or_else(|| "Untitled group".to_string()); + let desc = group + .metadata + .as_ref() + .and_then(|meta| meta.description.clone()) + .unwrap_or_default(); + let updated_at = group.metadata.as_ref().map(|meta| meta.updated_at).unwrap_or(0); + SearchDoc::new( + format!("group:{}:summary", group_id), + SearchResultKind::GroupSummary, + None, + Some(group_id.clone()), + None, + None, + name, + truncate_text(&desc, MAX_BODY_LEN), + Some(updated_at), + ) +} + +fn build_group_message_doc( + group_id: &GroupId, + group: &Group, + msg: &crate::crdt::MessageMeta, +) -> Option { + let name = group + .metadata + .as_ref() + .map(|meta| meta.name.clone()) + .unwrap_or_else(|| "Untitled group".to_string()); + SearchDoc::new( + format!("group:{}:msg:{}", group_id, msg.message_id), + SearchResultKind::GroupMessage, + None, + Some(group_id.clone()), + Some(msg.message_id.clone()), + Some(msg.thread_id.clone()), + name, + truncate_text(&msg.body, MAX_BODY_LEN), + Some(msg.timestamp), + ) +} + +fn doc_matches_scope(kind: SearchResultKind, scope: SearchScope) -> bool { + match scope { + SearchScope::All => true, + SearchScope::Chats => matches!( + kind, + SearchResultKind::ChatSummary | SearchResultKind::ChatMessage + ), + SearchScope::Groups => matches!( + kind, + SearchResultKind::GroupSummary | SearchResultKind::GroupMessage + ), + SearchScope::Messages => matches!( + kind, + SearchResultKind::ChatMessage | SearchResultKind::GroupMessage + ), + } +} + +fn score_doc(doc: &SearchDoc, query_tokens: &[String]) -> i64 { + let mut score = 0i64; + let title_lower = doc.title.to_lowercase(); + for token in query_tokens { + if doc.tokens.contains(token) { + score += 10; + } else if doc.tokens.iter().any(|t| t.starts_with(token)) { + score += 5; + } + if title_lower.contains(token) { + score += 3; + } + } + if matches!( + doc.kind, + SearchResultKind::ChatSummary | SearchResultKind::GroupSummary + ) { + score += 2; + } + score +} + +fn doc_to_result(doc: &SearchDoc, query_tokens: &[String]) -> SearchResultItem { + let snippet = if doc.body.is_empty() { + None + } else { + build_snippet(&doc.body, query_tokens) + }; + SearchResultItem { + kind: doc.kind, + chat_id: doc.chat_id.clone(), + group_id: doc.group_id.clone(), + message_id: doc.message_id.clone(), + thread_id: doc.thread_id.clone(), + title: doc.title.clone(), + snippet, + timestamp: doc.timestamp, + } +} + +fn build_snippet(text: &str, query_tokens: &[String]) -> Option { + if text.trim().is_empty() { + return None; + } + let lower = text.to_lowercase(); + let mut best_pos: Option<(usize, usize)> = None; + for token in query_tokens { + if let Some(pos) = lower.find(token) { + let char_pos = lower[..pos].chars().count(); + let token_chars = token.chars().count(); + let end_pos = char_pos + token_chars; + best_pos = match best_pos { + None => Some((char_pos, end_pos)), + Some((best_start, best_end)) => { + if char_pos < best_start { + Some((char_pos, end_pos)) + } else { + Some((best_start, best_end)) + } + } + }; + } + } + + let chars: Vec = text.chars().collect(); + let total = chars.len(); + let (start, end) = match best_pos { + Some((start, end)) => { + let window = 40; + let start = start.saturating_sub(window); + let end = (end + window).min(total); + (start, end) + } + None => { + let end = total.min(80); + (0, end) + } + }; + let mut snippet: String = chars[start..end].iter().collect(); + if start > 0 { + snippet = format!("...{}", snippet); + } + if end < total { + snippet.push_str("..."); + } + Some(snippet) +} + +fn truncate_text(text: &str, max_len: usize) -> String { + if text.chars().count() <= max_len { + return text.to_string(); + } + text.chars().take(max_len).collect() +} + +fn tokenize(text: &str) -> HashSet { + let mut tokens = HashSet::new(); + let mut buf = String::new(); + for ch in text.chars() { + if ch.is_ascii_alphanumeric() { + buf.push(ch.to_ascii_lowercase()); + } else if !buf.is_empty() { + if buf.len() >= 2 { + tokens.insert(buf.clone()); + } + buf.clear(); + } + } + if !buf.is_empty() && buf.len() >= 2 { + tokens.insert(buf); + } + tokens +} + +fn tokenize_query(text: &str) -> Vec { + let mut tokens = Vec::new(); + let mut buf = String::new(); + for ch in text.chars() { + if ch.is_ascii_alphanumeric() { + buf.push(ch.to_ascii_lowercase()); + } else if !buf.is_empty() { + if buf.len() >= 2 { + tokens.push(buf.clone()); + } + buf.clear(); + } + } + if !buf.is_empty() && buf.len() >= 2 { + tokens.push(buf); + } + tokens.sort(); + tokens.dedup(); + tokens +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} diff --git a/hyperdrive/packages/homepage/chat/src/types/api.rs b/hyperdrive/packages/homepage/chat/src/types/api.rs new file mode 100644 index 000000000..4a057f311 --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/types/api.rs @@ -0,0 +1,631 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt; + +use crate::crdt::{ + AttachmentDescriptor, DeliveryCursor, Group, GroupId, GroupMetadata, GroupRoutingConfig, + GroupVisibility, MembershipDecision, MembershipRuleConfig, MembershipRuleError, MessageId, + MessageMeta, NodeId, ThreadId, +}; + +use super::{ + default_group_message_type, FileInfo, MessageType, ReplicationMetrics, + SubscriberDeliveryEvent, +}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateChatReq { + pub counterparty: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GetChatReq { + pub chat_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GetMessagesReq { + pub chat_id: String, + pub before_timestamp: Option, + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GetSyncHashReq { + pub chat_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SyncHashInfo { + pub chat_id: String, + pub message_count: u32, + pub last_message_id: Option, + pub last_message_timestamp: Option, + pub hash: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct DeleteChatReq { + pub chat_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UpdateChatSettingsReq { + pub chat_id: String, + pub notify: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SendMessageReq { + pub chat_id: String, + pub content: String, + pub reply_to: Option, + pub file_info: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct EditMessageReq { + pub chat_id: String, + pub message_id: String, + pub new_content: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct DeleteMessageReq { + pub chat_id: String, + pub message_id: String, + pub delete_for_both: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AddReactionReq { + pub chat_id: String, + pub message_id: String, + pub emoji: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct RemoveReactionReq { + pub chat_id: String, + pub message_id: String, + pub emoji: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ForwardMessageReq { + pub from_chat_id: String, + pub message_id: String, + pub to_chat_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateChatLinkReq { + pub chat_id: String, + pub single_use: bool, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct RevokeChatKeyReq { + pub key: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UploadFileReq { + pub chat_id: String, + pub filename: String, + pub mime_type: String, + pub data: String, + pub reply_to: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UploadGroupFileReq { + pub group_id: GroupId, + #[serde(default)] + pub thread_id: Option, + #[serde(default)] + pub reply_to: Option, + pub filename: String, + pub mime_type: String, + pub data: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct DownloadFileReq { + pub chat_id: String, + pub file_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct DownloadGroupFileReq { + pub group_id: GroupId, + pub attachment_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct FetchGroupFileReq { + pub group_id: GroupId, + pub attachment_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UploadProfilePictureReq { + pub mime_type: String, + pub data: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SendVoiceNoteReq { + pub chat_id: String, + pub audio_data: String, + pub duration: u32, + pub reply_to: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SendGroupVoiceNoteReq { + pub group_id: GroupId, + #[serde(default)] + pub thread_id: Option, + #[serde(default)] + pub reply_to: Option, + pub audio_data: String, + pub duration: u32, + pub mime_type: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SearchChatsReq { + pub query: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchScope { + Chats, + Groups, + Messages, + All, +} + +impl Default for SearchScope { + fn default() -> Self { + SearchScope::All + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchResultKind { + ChatSummary, + ChatMessage, + GroupSummary, + GroupMessage, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SearchIndexReq { + pub query: String, + #[serde(default)] + pub scope: SearchScope, + #[serde(default)] + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SearchResultItem { + pub kind: SearchResultKind, + #[serde(default)] + pub chat_id: Option, + #[serde(default)] + pub group_id: Option, + #[serde(default)] + pub message_id: Option, + #[serde(default)] + pub thread_id: Option, + pub title: String, + #[serde(default)] + pub snippet: Option, + #[serde(default)] + pub timestamp: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SearchIndexRes { + pub results: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateGroupReq { + #[serde(default)] + pub group_id: Option, + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub avatar: Option, + #[serde(default)] + pub visibility: Option, + #[serde(default)] + pub default_role_label: Option, + #[serde(default)] + pub membership_rules: Vec, + #[serde(default)] + pub root_thread_title: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateGroupRes { + pub group_id: GroupId, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateGroupThreadReq { + pub group_id: GroupId, + #[serde(default)] + pub parent_thread_id: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub root_message_id: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateGroupThreadRes { + pub thread_id: ThreadId, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SendGroupMessageReq { + pub group_id: GroupId, + #[serde(default)] + pub thread_id: Option, + pub content: String, + #[serde(default = "default_group_message_type")] + pub message_type: MessageType, + #[serde(default)] + pub reply_to: Option, + #[serde(default)] + pub attachments: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SendGroupMessageRes { + pub message: MessageMeta, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct EditGroupMessageReq { + pub group_id: GroupId, + pub message_id: MessageId, + pub new_content: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct DeleteGroupMessageReq { + pub group_id: GroupId, + pub message_id: MessageId, + #[serde(default)] + pub delete_for_both: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AddGroupReactionReq { + pub group_id: GroupId, + pub message_id: MessageId, + pub emoji: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct RemoveGroupReactionReq { + pub group_id: GroupId, + pub message_id: MessageId, + pub emoji: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GetGroupReq { + pub group_id: GroupId, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GetGroupRes { + pub group: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GroupSummary { + pub group_id: GroupId, + pub metadata: Option, + pub member_count: usize, + pub thread_count: usize, + #[serde(default)] + pub unread_count: u32, + #[serde(default = "default_true")] + pub notify: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UpdateGroupSettingsReq { + pub group_id: GroupId, + pub notify: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ListGroupsRes { + pub groups: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateGroupJoinLinkReq { + pub group_id: GroupId, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateGroupJoinLinkRes { + pub link: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct JoinGroupLinkReq { + pub host: NodeId, + pub key: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct JoinGroupLinkRemoteReq { + pub key: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct JoinGroupLinkRes { + pub group_id: GroupId, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct InviteGroupMemberReq { + pub group_id: GroupId, + pub candidate: NodeId, + pub role_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ApproveGroupMembershipReq { + pub group_id: GroupId, + pub proposal_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct RemoveGroupMemberReq { + pub group_id: GroupId, + pub member: NodeId, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct MembershipDecisionRes { + pub decision: MembershipDecision, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CrdtStateVectorRes { + pub state_vector: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CrdtGroupStateVectorReq { + pub group_id: GroupId, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CrdtGroupUpdateReq { + pub group_id: GroupId, + #[serde(default)] + pub state_vector: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CrdtUpdateRes { + pub doc_id: String, + pub update_payload: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CrdtGroupApplyReq { + pub group_id: GroupId, + pub update_payload: String, + #[serde(default)] + pub acl_version: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CrdtGroupSnapshotReq { + pub group_id: GroupId, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CrdtApplyRes { + pub applied: bool, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AdminReplicationStateReq { + #[serde(default)] + pub group_id: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GroupReplicationState { + pub group_id: GroupId, + pub pending_bootstrap: bool, + pub routing: GroupRoutingConfig, + pub hubs: Vec, + pub subscribers: Vec, + pub hub_cursors: HashMap, + pub subscriber_cursors: HashMap, + #[serde(default)] + pub whitelist_version: Option, + #[serde(default)] + pub subscriber_lag_secs: Option, + #[serde(default)] + pub hub_lag_secs: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AdminReplicationStateRes { + pub metrics: ReplicationMetrics, + pub groups: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AdminWhitelistReq { + pub group_id: GroupId, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct WhitelistEntryDebug { + pub node: String, + pub publish: Vec, + pub subscribe: Vec, + pub audiences: Vec, + pub features: Vec, + #[serde(default)] + pub expires_at: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AdminWhitelistRes { + pub group_id: GroupId, + pub version: u64, + pub entries: Vec, +} + +/// Request to immediately push a snapshot to a specific peer (bypassing the debounced queue). +/// Used when a member is invited to get them bootstrapped immediately. +#[derive(Serialize, Deserialize, Debug)] +pub struct PushSnapshotToPeerReq { + pub group_id: GroupId, + pub peer: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SubscriberEventsReq { + #[serde(default)] + pub take: Option, + #[serde(default)] + pub clear: bool, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SubscriberEventsRes { + pub events: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, process_macros::SerdeJsonInto)] +pub enum HomepageRequest { + GetPushSubscription, +} + +#[derive(Serialize, Deserialize, Clone, Debug, process_macros::SerdeJsonInto)] +pub enum HomepageResponse { + PushSubscription(Option), +} + +#[derive(Debug)] +pub enum MembershipActionError { + GroupNotFound(GroupId), + MemberExists(NodeId), + MemberNotFound(NodeId), + ProposalExists(String), + ProposalNotFound(String), + RuleError(MembershipRuleError), + PermissionDenied(String), +} + +impl From for MembershipActionError { + fn from(err: MembershipRuleError) -> Self { + MembershipActionError::RuleError(err) + } +} + +impl fmt::Display for MembershipActionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MembershipActionError::GroupNotFound(id) => write!(f, "group '{id}' not found"), + MembershipActionError::MemberExists(node) => { + write!(f, "member '{node}' already exists in group") + } + MembershipActionError::MemberNotFound(node) => { + write!(f, "member '{node}' not found in group") + } + MembershipActionError::ProposalExists(id) => { + write!(f, "proposal '{id}' already exists") + } + MembershipActionError::ProposalNotFound(id) => { + write!(f, "proposal '{id}' not found") + } + MembershipActionError::RuleError(err) => write!(f, "rule error: {}", err), + MembershipActionError::PermissionDenied(msg) => { + write!(f, "permission denied: {}", msg) + } + } + } +} + +impl std::error::Error for MembershipActionError {} + +// Spider Integration Types + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpiderApiKey { + pub key: String, + pub name: String, + pub permissions: Vec, + #[serde(rename = "createdAt")] + pub created_at: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpiderConnectResult { + pub api_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpiderStatusInfo { + pub connected: bool, + pub has_api_key: bool, + #[serde(rename = "spider_available")] + pub spider_available: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpiderMessageContent { + #[serde(default)] + pub text: Option, + #[serde(default)] + pub audio: Option>, + #[serde(rename = "base-six-four-audio", default)] + pub base_six_four_audio: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpiderMessage { + pub role: String, + pub content: SpiderMessageContent, + #[serde(rename = "tool-calls-json", default)] + pub tool_calls_json: Option, + #[serde(rename = "tool-results-json", default)] + pub tool_results_json: Option, + pub timestamp: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpiderHistory { + pub messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpiderSetHistoryReq { + pub messages: Vec, +} diff --git a/hyperdrive/packages/homepage/chat/src/types/mod.rs b/hyperdrive/packages/homepage/chat/src/types/mod.rs new file mode 100644 index 000000000..231a244c5 --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/types/mod.rs @@ -0,0 +1,16 @@ +//! Types module layout: +//! - `model.rs`: core data models (messages, chats, settings, notifications). +//! - `api.rs`: wire request/response DTOs for HTTP/RPC/WebSocket/admin. +//! - `replication.rs`: replication queue/wake types and metrics. +//! - `state.rs`: ChatState, runtime channels, and serde wiring. +//! Re-exported for ergonomic `use crate::types::*`. + +mod api; +mod model; +mod replication; +mod state; + +pub use api::*; +pub use model::*; +pub use replication::*; +pub use state::*; diff --git a/hyperdrive/packages/homepage/chat/src/types/model.rs b/hyperdrive/packages/homepage/chat/src/types/model.rs new file mode 100644 index 000000000..9b3527d77 --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/types/model.rs @@ -0,0 +1,448 @@ +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::crdt::{ + Group, GroupId, GroupTier, MembershipActionKind, MembershipDecision, MembershipDecisionStatus, + MembershipProposal, MembershipRuleBox, MembershipRuleConfig, MembershipStatus, NodeId, + SubscriberSyncState, ThreadId, +}; +use hyperware_process_lib::our; + +/// Group/membership helpers shared across state and group modules. +pub(crate) fn aggregate_rule_decisions( + rules: &[MembershipRuleBox], + proposal: &MembershipProposal, +) -> MembershipDecision { + if rules.is_empty() { + return MembershipDecision::approved(); + } + + let mut pending: std::collections::HashSet = std::collections::HashSet::new(); + for rule in rules { + let decision = rule.evaluate(proposal); + match decision.status { + MembershipDecisionStatus::Approved => {} + MembershipDecisionStatus::Pending => { + for sig in decision.missing_signatures { + pending.insert(sig); + } + } + MembershipDecisionStatus::Rejected => return decision, + } + } + + if pending.is_empty() { + MembershipDecision::approved() + } else { + let mut missing: Vec = pending.into_iter().collect(); + missing.sort(); + MembershipDecision::pending(missing) + } +} + +pub(crate) fn active_member_count(group: &Group) -> u32 { + group + .members + .values() + .filter(|member| member.status == MembershipStatus::Active) + .count() as u32 +} + +pub(crate) fn membership_proposal_key( + group_id: &GroupId, + candidate: &NodeId, + action: MembershipActionKind, +) -> String { + let action_str = match action { + MembershipActionKind::Invite => "invite", + MembershipActionKind::Remove => "remove", + }; + format!("{group_id}:{action_str}:{candidate}") +} + +pub(crate) fn current_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +pub(crate) fn ensure_membership_rules( + mut rules: Vec, + creator: &NodeId, +) -> Vec { + if rules.is_empty() { + rules = default_membership_rules(creator); + } + rules +} + +fn default_membership_rules(creator: &NodeId) -> Vec { + vec![MembershipRuleConfig::new( + "membership.rule.dictator", + json!({ "dictator": creator }), + )] +} + +pub(crate) fn default_group_message_type() -> MessageType { + MessageType::Text +} + +pub(crate) fn group_root_thread_id(group: &Group) -> Option { + group + .metadata + .as_ref() + .map(|metadata| metadata.root_thread_id.clone()) +} + +pub(crate) fn sync_member_membership_sets(group: &mut Group, member_id: &NodeId, timestamp: u64) { + let Some(member) = group.members.get(member_id) else { + group.hubs.active.remove(member_id); + group.subscribers.entries.remove(member_id); + return; + }; + + if member.status == MembershipStatus::Removed { + group.hubs.active.remove(member_id); + group.subscribers.entries.remove(member_id); + return; + } + + if let Some(role) = group.roles.get(&member.role_id) { + match role.tier { + GroupTier::Hub => { + group.hubs.active.insert(member_id.clone()); + } + GroupTier::Subscriber => { + group.hubs.active.remove(member_id); + } + } + } + + let state = group + .subscribers + .entries + .entry(member_id.clone()) + .or_insert_with(SubscriberSyncState::default); + state.last_seen_ts = timestamp; +} + +pub(crate) fn generate_group_id() -> GroupId { + let timestamp = current_timestamp(); + let nonce: u32 = rand::random(); + format!("group:{}:{}:{}", our().node, timestamp, nonce) +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct PushSubscription { + pub endpoint: String, + pub keys: SubscriptionKeys, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SubscriptionKeys { + pub p256dh: String, + pub auth: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum NotificationsAction { + SendNotification { + title: String, + body: String, + icon: Option, + data: Option, + }, + GetPublicKey, + InitializeKeys, + AddSubscription { + subscription: PushSubscription, + }, + RemoveSubscription { + endpoint: String, + }, + ClearSubscriptions, +} + +#[derive(Serialize, Deserialize, Debug)] +pub enum NotificationsResponse { + NotificationSent, + PublicKey(String), + KeysInitialized, + SubscriptionAdded, + SubscriptionRemoved, + SubscriptionsCleared, + Err(String), +} + +#[derive(Serialize, Clone, Debug, PartialEq)] +pub struct ChatMessage { + pub id: String, + pub sender: String, + pub content: String, + pub timestamp: u64, + #[serde(default)] + pub sequence: Option, + pub status: MessageStatus, + pub reply_to: Option, + pub reactions: Vec, + pub message_type: MessageType, + pub file_info: Option, +} + +impl<'de> Deserialize<'de> for ChatMessage { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct ChatMessageV2 { + id: String, + sender: String, + content: String, + timestamp: u64, + #[serde(default)] + sequence: Option, + status: MessageStatus, + reply_to: Option, + reactions: Vec, + message_type: MessageType, + file_info: Option, + } + + #[derive(Deserialize)] + struct ChatMessageV1 { + id: String, + sender: String, + content: String, + timestamp: u64, + status: MessageStatus, + reply_to: Option, + reactions: Vec, + message_type: MessageType, + file_info: Option, + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum ChatMessageCompat { + V2(ChatMessageV2), + V1(ChatMessageV1), + } + + match ChatMessageCompat::deserialize(deserializer)? { + ChatMessageCompat::V2(msg) => Ok(ChatMessage { + id: msg.id, + sender: msg.sender, + content: msg.content, + timestamp: msg.timestamp, + sequence: msg.sequence, + status: msg.status, + reply_to: msg.reply_to, + reactions: msg.reactions, + message_type: msg.message_type, + file_info: msg.file_info, + }), + ChatMessageCompat::V1(msg) => Ok(ChatMessage { + id: msg.id, + sender: msg.sender, + content: msg.content, + timestamp: msg.timestamp, + sequence: None, + status: msg.status, + reply_to: msg.reply_to, + reactions: msg.reactions, + message_type: msg.message_type, + file_info: msg.file_info, + }), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct MessageReaction { + pub emoji: String, + pub user: String, + pub timestamp: u64, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum MessageType { + Text, + Image, + File, + VoiceNote, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct FileInfo { + pub filename: String, + pub mime_type: String, + pub size: u64, + /// Encoded file reference. Expected variants: + /// - `data:;base64,` for inline images. + /// - `compressed:` for non-image payloads we compress before send. + /// - `/files//` or other VFS paths once stored locally. + /// Other strings are treated as opaque remote paths. + pub url: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileUrlKind<'a> { + DataUrl(&'a str), + CompressedBase64(&'a str), + VfsPath(&'a str), + Other(&'a str), +} + +impl FileInfo { + pub fn url_kind(&self) -> FileUrlKind<'_> { + if let Some(rest) = self.url.strip_prefix("compressed:") { + FileUrlKind::CompressedBase64(rest) + } else if self.url.starts_with("data:") { + FileUrlKind::DataUrl(&self.url) + } else if self.url.starts_with("/files/") { + FileUrlKind::VfsPath(&self.url) + } else { + FileUrlKind::Other(&self.url) + } + } +} + +/// MessageStatus lifecycle: outbound messages start at `Sending`, flip to `Sent` +/// once stored locally, move to `Delivered` on ack/receipt from the counterparty, +/// and may be marked `Failed` by delivery retries if a destination remains unreachable. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub enum MessageStatus { + Sending, + Sent, + Delivered, + Failed, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct Chat { + pub id: String, + pub counterparty: String, + pub messages: Vec, + pub last_activity: u64, + pub unread_count: u32, + pub is_blocked: bool, + pub notify: bool, + #[serde(default)] + pub counterparty_profile: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct ChatKey { + pub key: String, + pub user_name: String, + pub created_at: u64, + pub is_revoked: bool, + pub chat_id: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct GroupJoinKey { + pub key: String, + pub group_id: GroupId, + pub created_at: u64, + pub is_revoked: bool, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] +pub struct UserProfile { + pub name: String, + pub profile_pic: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct Settings { + pub show_images: bool, + pub show_profile_pics: bool, + pub combine_chats_groups: bool, + pub notify_chats: bool, + pub notify_groups: bool, + pub notify_calls: bool, + pub allow_browser_chats: bool, + pub stt_enabled: bool, + pub stt_api_key: Option, + pub max_file_size_mb: u64, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + show_images: true, + show_profile_pics: true, + combine_chats_groups: false, + notify_chats: true, + notify_groups: true, + notify_calls: true, + allow_browser_chats: true, + stt_enabled: false, + stt_api_key: None, + max_file_size_mb: 10, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum WsClientMessage { + SendMessage { + chat_id: String, + content: String, + reply_to: Option, + }, + Ack { + message_id: String, + }, + MarkRead { + chat_id: String, + }, + MarkGroupRead { + group_id: String, + }, + UpdateStatus { + status: String, + }, + AuthWithKey { + chat_key: String, + }, + BrowserMessage { + content: String, + }, + Heartbeat, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum WsServerMessage { + NewMessage(ChatMessage), + MessageAck { + message_id: String, + }, + StatusUpdate { + node: String, + status: String, + }, + ChatUpdate(Chat), + ProfileUpdate { + node: String, + profile: UserProfile, + }, + AuthSuccess { + chat_id: String, + history: Vec, + }, + AuthFailed { + reason: String, + }, + GroupUpdate { + group_id: String, + }, + Heartbeat, + Error { + message: String, + }, +} diff --git a/hyperdrive/packages/homepage/chat/src/types/replication.rs b/hyperdrive/packages/homepage/chat/src/types/replication.rs new file mode 100644 index 000000000..2b65faeed --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/types/replication.rs @@ -0,0 +1,113 @@ +use futures::channel::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use serde::{Deserialize, Serialize}; + +use crate::crdt::GroupId; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct ReplicationMetrics { + #[serde(default)] + pub acl_skips: u64, + #[serde(default)] + pub retries: u64, + #[serde(default)] + pub drops: u64, + #[serde(default)] + pub stale_replays: u64, + #[serde(default)] + pub last_lag_secs: u64, + #[serde(default)] + pub last_subscriber_lag_secs: u64, + #[serde(default)] + pub acl_drifts: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SubscriberDeliveryEvent { + pub group_id: GroupId, + pub topic: String, + pub offset: u64, + pub kind: ReplicationKind, + pub age_secs: u64, + pub recorded_at: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum ReplicationKind { + PushDelta, + PushSnapshot, + PullSnapshot, + PullDelta, +} + +impl Default for ReplicationKind { + fn default() -> Self { + ReplicationKind::PushDelta + } +} + +#[derive(Clone, Debug)] +pub struct ReplicationTask { + pub group_id: GroupId, + pub peer: String, + pub kind: ReplicationKind, + pub since: Option>, + pub attempt: u32, + pub not_before: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BrokerEnvelope { + pub offset: u64, + pub payload: String, + #[serde(default)] + pub acl_version: Option, + #[serde(default)] + pub kind: ReplicationKind, + #[serde(default)] + pub ts: u64, +} + +#[derive(Clone)] +pub struct ReplicationTx { + sender: UnboundedSender, +} + +impl ReplicationTx { + pub fn new() -> (Self, UnboundedReceiver) { + let (sender, receiver) = mpsc::unbounded(); + (ReplicationTx { sender }, receiver) + } + + pub fn unbounded_send( + &self, + task: ReplicationTask, + ) -> Result<(), mpsc::TrySendError> { + self.sender.unbounded_send(task) + } +} + +#[derive(Clone)] +pub struct ReplicationWakeTx { + sender: UnboundedSender<()>, +} + +pub struct ReplicationWakeRx { + receiver: UnboundedReceiver<()>, +} + +impl ReplicationWakeTx { + pub fn new() -> (Self, ReplicationWakeRx) { + let (sender, receiver) = mpsc::unbounded(); + (ReplicationWakeTx { sender }, ReplicationWakeRx { receiver }) + } + + pub fn wake(&self) { + let _ = self.sender.unbounded_send(()); + } +} + +impl ReplicationWakeRx { + pub fn into_stream(self) -> UnboundedReceiver<()> { + self.receiver + } +} diff --git a/hyperdrive/packages/homepage/chat/src/types/state.rs b/hyperdrive/packages/homepage/chat/src/types/state.rs new file mode 100644 index 000000000..700c4c14a --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/types/state.rs @@ -0,0 +1,1327 @@ +use futures::channel::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::crdt::{ + Group, GroupCrdtManager, GroupId, GroupMember, GroupMetadata, GroupRoutingConfig, GroupTier, + GroupVisibility, HubSyncState, MembershipRuleBox, MembershipStatus, NodeId, Role, + SubscriberSyncState, Thread, ThreadParentRef, +}; +use crate::pubsub::PubSubRegistry; +use crate::search::SearchIndex; +use hyperware_crdt::yrs::Encode; +use hyperware_crdt::CommitteeError; +use hyperware_process_lib::our; +use hyperware_pubsub_core::{whitelist::NodeId as BrokerNodeId, TopicId as BrokerTopicId}; + +use super::api::*; +use super::model::*; +use super::model::{current_timestamp, ensure_membership_rules, generate_group_id, group_root_thread_id}; +use super::replication::{ + BrokerEnvelope, ReplicationKind, ReplicationMetrics, ReplicationTask, ReplicationTx, + ReplicationWakeRx, ReplicationWakeTx, SubscriberDeliveryEvent, +}; +use crate::WsServerMessage; + +#[derive(Clone, Debug)] +pub enum DeliveryEvent { + Message(ChatMessage), + Flush, +} + +#[derive(Clone, Debug)] +pub struct QueuedDelivery { + pub node: String, + pub event: DeliveryEvent, +} + +impl QueuedDelivery { + pub fn message(node: String, message: ChatMessage) -> Self { + Self { + node, + event: DeliveryEvent::Message(message), + } + } + + pub fn flush(node: String) -> Self { + Self { + node, + event: DeliveryEvent::Flush, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chat_state_runtime_fields_are_not_serialized() { + let mut state = ChatState::default(); + state.profile.name = "alice".to_string(); + state.ws_connections.insert(1, "peer".to_string()); + state.browser_connections.insert("browser".to_string(), 2); + state.last_heartbeat.insert(3, 123); + state.active_connections.insert(4); + state.node_profiles.insert( + "peer".to_string(), + UserProfile { + name: "bob".into(), + profile_pic: None, + }, + ); + + let value = serde_json::to_value(&state).expect("serialize ChatState"); + + assert!(value.get("ws_connections").is_none()); + assert!(value.get("browser_connections").is_none()); + assert!(value.get("last_heartbeat").is_none()); + assert!(value.get("active_connections").is_none()); + assert!(value.get("node_profiles").is_none()); + + let restored: ChatState = serde_json::from_value(value).expect("deserialize ChatState"); + + assert_eq!(restored.profile.name, "alice"); + assert!(restored.ws_connections.is_empty()); + assert!(restored.browser_connections.is_empty()); + assert!(restored.last_heartbeat.is_empty()); + assert!(restored.active_connections.is_empty()); + assert!(restored.node_profiles.is_empty()); + } + + #[test] + fn legacy_master_state_deserializes() { + use serde::{Deserialize, Serialize}; + use std::collections::{HashMap, HashSet}; + + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] + struct LegacyChatMessage { + id: String, + sender: String, + content: String, + timestamp: u64, + status: MessageStatus, + reply_to: Option, + reactions: Vec, + message_type: MessageType, + file_info: Option, + } + + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] + struct LegacyChat { + id: String, + counterparty: String, + messages: Vec, + last_activity: u64, + unread_count: u32, + is_blocked: bool, + notify: bool, + #[serde(default)] + counterparty_profile: Option, + } + + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] + struct LegacyChatState { + profile: UserProfile, + chats: HashMap, + chat_keys: HashMap, + settings: Settings, + delivery_queue: HashMap>, + online_nodes: HashSet, + ws_connections: HashMap, + browser_connections: HashMap, + last_heartbeat: HashMap, + #[serde(default)] + active_connections: HashSet, + #[serde(default)] + node_profiles: HashMap, + } + + let message = LegacyChatMessage { + id: "123:1".to_string(), + sender: "bob.node".to_string(), + content: "hi".to_string(), + timestamp: 123, + status: MessageStatus::Delivered, + reply_to: None, + reactions: Vec::new(), + message_type: MessageType::Text, + file_info: None, + }; + let chat = LegacyChat { + id: "alice.node:bob.node".to_string(), + counterparty: "bob.node".to_string(), + messages: vec![message.clone()], + last_activity: 123, + unread_count: 0, + is_blocked: false, + notify: true, + counterparty_profile: Some(UserProfile { + name: "bob".to_string(), + profile_pic: None, + }), + }; + let legacy = LegacyChatState { + profile: UserProfile { + name: "alice".to_string(), + profile_pic: None, + }, + chats: HashMap::from([(chat.id.clone(), chat)]), + chat_keys: HashMap::new(), + settings: Settings::default(), + delivery_queue: HashMap::new(), + online_nodes: HashSet::new(), + ws_connections: HashMap::new(), + browser_connections: HashMap::new(), + last_heartbeat: HashMap::new(), + active_connections: HashSet::new(), + node_profiles: HashMap::from([( + "bob.node".to_string(), + UserProfile { + name: "bob".to_string(), + profile_pic: None, + }, + )]), + }; + + let bytes = rmp_serde::to_vec(&legacy).expect("serialize legacy state via rmp"); + let restored: ChatState = rmp_serde::from_slice(&bytes).expect("deserialize into new state"); + + assert_eq!(restored.profile.name, "alice"); + assert_eq!(restored.chats.len(), 1); + let restored_chat = restored + .chats + .get("alice.node:bob.node") + .expect("chat should deserialize"); + assert_eq!(restored_chat.messages.len(), 1); + let restored_message = restored_chat.messages.first().unwrap(); + assert_eq!(restored_message.id, message.id); + assert_eq!(restored_message.sender, message.sender); + assert_eq!(restored_message.content, message.content); + assert_eq!(restored_message.timestamp, message.timestamp); + assert_eq!(restored_message.sequence, None); + assert_eq!(restored_message.status, message.status); + assert!(restored.groups.is_empty()); + assert_eq!( + restored.node_profiles.get("bob.node").map(|p| p.name.as_str()), + Some("bob") + ); + } + + #[test] + fn legacy_invite_only_groups_deserialize_as_public() { + use serde::{Deserialize, Serialize}; + use std::collections::HashMap; + + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] + enum LegacyGroupVisibility { + Private, + InviteOnly, + Public, + } + + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] + struct LegacyGroupMetadata { + name: String, + description: Option, + avatar: Option, + creator_id: String, + created_at: u64, + updated_at: u64, + visibility: LegacyGroupVisibility, + default_role_id: String, + root_thread_id: String, + } + + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] + struct LegacyGroup { + #[serde(default)] + metadata: Option, + } + + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] + struct LegacyChatStateV2 { + profile: UserProfile, + chats: HashMap, + chat_keys: HashMap, + settings: Settings, + message_sequence_counters: HashMap, + groups: HashMap, + group_unread: HashMap, + group_notify: HashMap, + node_profiles: HashMap, + } + + let group_id = "group:alice.node:123:0".to_string(); + let legacy_group = LegacyGroup { + metadata: Some(LegacyGroupMetadata { + name: "Legacy Group".to_string(), + description: None, + avatar: None, + creator_id: "alice.node".to_string(), + created_at: 100, + updated_at: 200, + visibility: LegacyGroupVisibility::InviteOnly, + default_role_id: format!("{group_id}:member"), + root_thread_id: format!("{group_id}:thread:root"), + }), + }; + + let legacy = LegacyChatStateV2 { + profile: UserProfile { + name: "alice".to_string(), + profile_pic: None, + }, + chats: HashMap::new(), + chat_keys: HashMap::new(), + settings: Settings::default(), + message_sequence_counters: HashMap::new(), + groups: HashMap::from([(group_id.clone(), legacy_group)]), + group_unread: HashMap::new(), + group_notify: HashMap::new(), + node_profiles: HashMap::new(), + }; + + let bytes = rmp_serde::to_vec(&legacy).expect("serialize legacy state via rmp"); + let restored: ChatState = rmp_serde::from_slice(&bytes).expect("deserialize into new state"); + + let restored_group = restored + .groups + .get(&group_id) + .expect("group should deserialize"); + let restored_visibility = restored_group + .metadata + .as_ref() + .map(|meta| meta.visibility); + assert_eq!(restored_visibility, Some(GroupVisibility::Public)); + assert!(restored.group_join_keys.is_empty()); + } +} + +#[derive(Clone)] +pub struct DeliveryTx { + sender: UnboundedSender, +} + +/// Persisted fields: profile, chats, chat_keys, group_join_keys, settings, message_sequence_counters, groups. +/// Runtime-only state (connections, heartbeats, channels, replication queues, caches, pubsub) is +/// skipped during serialization and rebuilt on startup. +#[derive(Serialize)] +pub struct ChatState { + pub profile: UserProfile, + pub chats: HashMap, + pub chat_keys: HashMap, + #[serde(default)] + pub group_join_keys: HashMap, + pub settings: Settings, + #[serde(default)] + pub spider_api_key: Option, + #[serde(default)] + pub spider_history: Vec, + #[serde(default)] + pub message_sequence_counters: HashMap, + #[serde(skip)] + pub delivery_tx: DeliveryTx, + #[serde(skip)] + pub delivery_rx: Option>, + #[serde(skip)] + pub replication_tx: ReplicationTx, + #[serde(skip)] + pub replication_rx: Option>, + #[serde(skip)] + pub replication_wake_tx: Option, + #[serde(skip)] + pub replication_wake_rx: Option, + #[serde(skip)] + pub replication_work_inflight: Arc, + #[serde(skip)] + pub replication_queue: VecDeque, + #[serde(skip)] + pub broker_queues: HashMap>, + #[serde(skip)] + pub broker_offsets: HashMap, + #[serde(skip)] + pub broker_cursors: HashMap, + #[serde(skip)] + pub delivery_dedupe: HashMap, + #[serde(skip)] + pub subscriber_events: VecDeque, + #[serde(skip)] + pub replication_metrics: ReplicationMetrics, + #[serde(skip)] + pub pending_deliveries: Arc>>>, + #[serde(skip)] + pub ws_connections: HashMap, + #[serde(skip)] + pub browser_connections: HashMap, + #[serde(skip)] + pub last_heartbeat: HashMap, + #[serde(skip)] + pub active_connections: HashSet, + #[serde(skip)] + pub node_profiles: HashMap, + #[serde(default)] + pub groups: HashMap, + #[serde(default)] + pub group_unread: HashMap, + #[serde(default)] + pub group_notify: HashMap, + #[serde(skip)] + pub(crate) search_index: SearchIndex, + #[serde(skip)] + pub membership_rule_cache: HashMap>, + #[serde(skip)] + pub group_doc_managers: HashMap, + #[serde(skip)] + pub groups_pending_bootstrap: HashSet, + #[serde(skip)] + pub pubsub: PubSubRegistry, +} + +impl Default for ChatState { + fn default() -> Self { + let (delivery_tx, delivery_rx) = DeliveryTx::new(); + let (replication_tx, replication_rx) = ReplicationTx::new(); + let (replication_wake_tx, replication_wake_rx) = ReplicationWakeTx::new(); + + ChatState { + profile: UserProfile::default(), + chats: HashMap::new(), + chat_keys: HashMap::new(), + group_join_keys: HashMap::new(), + settings: Settings::default(), + spider_api_key: None, + spider_history: Vec::new(), + message_sequence_counters: HashMap::new(), + delivery_tx, + // represents "still available" versus "already consumed" + delivery_rx: Some(delivery_rx), + replication_tx, + replication_rx: Some(replication_rx), + replication_wake_tx: Some(replication_wake_tx), + replication_wake_rx: Some(replication_wake_rx), + replication_work_inflight: Arc::new(AtomicBool::new(false)), + replication_queue: VecDeque::new(), + broker_queues: HashMap::new(), + broker_offsets: HashMap::new(), + broker_cursors: HashMap::new(), + delivery_dedupe: HashMap::new(), + subscriber_events: VecDeque::new(), + replication_metrics: ReplicationMetrics::default(), + pending_deliveries: Arc::new(Mutex::new(HashMap::new())), + ws_connections: HashMap::new(), + browser_connections: HashMap::new(), + last_heartbeat: HashMap::new(), + active_connections: HashSet::new(), + node_profiles: HashMap::new(), + groups: HashMap::new(), + group_unread: HashMap::new(), + group_notify: HashMap::new(), + search_index: SearchIndex::default(), + membership_rule_cache: HashMap::new(), + group_doc_managers: HashMap::new(), + groups_pending_bootstrap: HashSet::new(), + pubsub: PubSubRegistry::new(), + } + } +} + +impl DeliveryTx { + pub fn new() -> (Self, UnboundedReceiver) { + let (sender, receiver) = mpsc::unbounded(); + (DeliveryTx { sender }, receiver) + } + + pub fn unbounded_send( + &self, + delivery: QueuedDelivery, + ) -> Result<(), mpsc::TrySendError> { + self.sender.unbounded_send(delivery) + } +} + +impl<'de> Deserialize<'de> for ChatState { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct ChatStateSerdeV2 { + profile: UserProfile, + chats: HashMap, + chat_keys: HashMap, + #[serde(default)] + group_join_keys: HashMap, + settings: Settings, + #[serde(default)] + spider_api_key: Option, + #[serde(default)] + spider_history: Vec, + #[serde(default)] + message_sequence_counters: HashMap, + #[serde(default)] + groups: HashMap, + #[serde(default)] + group_unread: HashMap, + #[serde(default)] + group_notify: HashMap, + #[serde(default)] + node_profiles: HashMap, + } + + #[derive(Deserialize)] + struct ChatStateSerdeV2Legacy { + profile: UserProfile, + chats: HashMap, + chat_keys: HashMap, + settings: Settings, + #[serde(default)] + message_sequence_counters: HashMap, + #[serde(default)] + groups: HashMap, + #[serde(default)] + group_unread: HashMap, + #[serde(default)] + group_notify: HashMap, + #[serde(default)] + node_profiles: HashMap, + } + + #[derive(Deserialize)] + #[allow(dead_code)] + struct ChatStateSerdeV1 { + profile: UserProfile, + chats: HashMap, + chat_keys: HashMap, + settings: Settings, + #[serde(default)] + delivery_queue: HashMap>, + #[serde(default)] + online_nodes: HashSet, + #[serde(default)] + ws_connections: HashMap, + #[serde(default)] + browser_connections: HashMap, + #[serde(default)] + last_heartbeat: HashMap, + #[serde(default)] + active_connections: HashSet, + #[serde(default)] + node_profiles: HashMap, + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum ChatStateCompat { + V2(ChatStateSerdeV2), + V2Legacy(ChatStateSerdeV2Legacy), + V1(ChatStateSerdeV1), + } + + let ( + profile, + chats, + chat_keys, + group_join_keys, + settings, + spider_api_key, + spider_history, + message_sequence_counters, + groups, + group_unread, + group_notify, + node_profiles, + ) = + match ChatStateCompat::deserialize(deserializer)? { + ChatStateCompat::V2(data) => ( + data.profile, + data.chats, + data.chat_keys, + data.group_join_keys, + data.settings, + data.spider_api_key, + data.spider_history, + data.message_sequence_counters, + data.groups, + data.group_unread, + data.group_notify, + data.node_profiles, + ), + ChatStateCompat::V2Legacy(data) => ( + data.profile, + data.chats, + data.chat_keys, + HashMap::new(), + data.settings, + None, + Vec::new(), + data.message_sequence_counters, + data.groups, + data.group_unread, + data.group_notify, + data.node_profiles, + ), + ChatStateCompat::V1(data) => ( + data.profile, + data.chats, + data.chat_keys, + HashMap::new(), + data.settings, + None, + Vec::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + data.node_profiles, + ), + }; + let (delivery_tx, delivery_rx) = DeliveryTx::new(); + let (replication_tx, replication_rx) = ReplicationTx::new(); + let (replication_wake_tx, replication_wake_rx) = ReplicationWakeTx::new(); + + let mut state = ChatState { + profile, + chats, + chat_keys, + group_join_keys, + settings, + spider_api_key, + spider_history, + message_sequence_counters, + delivery_tx, + delivery_rx: Some(delivery_rx), + replication_tx, + replication_rx: Some(replication_rx), + replication_wake_tx: Some(replication_wake_tx), + replication_wake_rx: Some(replication_wake_rx), + replication_work_inflight: Arc::new(AtomicBool::new(false)), + replication_queue: VecDeque::new(), + broker_queues: HashMap::new(), + broker_offsets: HashMap::new(), + broker_cursors: HashMap::new(), + delivery_dedupe: HashMap::new(), + subscriber_events: VecDeque::new(), + replication_metrics: ReplicationMetrics::default(), + pending_deliveries: Arc::new(Mutex::new(HashMap::new())), + ws_connections: HashMap::new(), + browser_connections: HashMap::new(), + last_heartbeat: HashMap::new(), + active_connections: HashSet::new(), + node_profiles, + groups, + group_unread, + group_notify, + search_index: SearchIndex::default(), + membership_rule_cache: HashMap::new(), + group_doc_managers: HashMap::new(), + groups_pending_bootstrap: HashSet::new(), + pubsub: PubSubRegistry::new(), + }; + + if !cfg!(test) { + if let Err(err) = state.rebuild_group_doc_managers() { + crate::log_debug!( + "Failed to rebuild group CRDT managers from snapshot: {:?}", + err + ); + } + } + + Ok(state) + } +} + +impl ChatState { + pub(crate) fn local_group_acl_ready(&self, group_id: &GroupId) -> bool { + let Some(whitelist) = self.pubsub.whitelist(group_id) else { + return false; + }; + let Some(routing) = self.pubsub.routing(group_id) else { + return false; + }; + let node = BrokerNodeId::new(our().node.clone()); + let now = SystemTime::now(); + let hub_ok = if routing.hub_topic.is_empty() { + false + } else { + let topic = BrokerTopicId::new(routing.hub_topic.clone()); + whitelist.subscribe_scope(&node, &topic, now).is_some() + || whitelist.publish_scope(&node, &topic, now).is_some() + }; + let sub_ok = if routing.subscriber_topic.is_empty() { + false + } else { + let topic = BrokerTopicId::new(routing.subscriber_topic.clone()); + whitelist.subscribe_scope(&node, &topic, now).is_some() + }; + hub_ok || sub_ok + } + + #[cfg(feature = "test-helpers")] + pub fn now_secs() -> u64 { + Self::now_secs_inner() + } + + #[cfg(not(feature = "test-helpers"))] + pub(crate) fn now_secs() -> u64 { + Self::now_secs_inner() + } + + fn now_secs_inner() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + } + + pub fn rebuild_group_doc_managers(&mut self) -> Result<(), CommitteeError> { + self.ensure_routing_defaults_for_all(); + self.group_doc_managers.clear(); + self.groups_pending_bootstrap.clear(); + for (group_id, group) in &self.groups { + // Skip groups where we've been removed - no need to bootstrap those + let is_removed = group + .members + .get(&our().node) + .map(|m| m.status == MembershipStatus::Removed) + .unwrap_or(false); + if is_removed { + continue; + } + + if self.should_seed_group_doc(group) { + let manager = GroupCrdtManager::from_group(group_id, group)?; + self.group_doc_managers.insert(group_id.clone(), manager); + } else { + self.groups_pending_bootstrap.insert(group_id.clone()); + } + } + self.pubsub.rebuild_all(&self.groups); + Ok(()) + } + + pub fn rebuild_pubsub_for_group(&mut self, group_id: &GroupId) { + if let Some(group) = self.groups.get(group_id) { + self.pubsub.rebuild_group(group_id, group); + } else { + self.pubsub.remove_group(group_id); + } + } + + pub fn rebuild_search_index(&mut self) { + let our_node = our().node.clone(); + self.search_index + .rebuild(&self.chats, &self.groups, &our_node); + } + + pub fn rebuild_chat_search(&mut self, chat_id: &str) { + if let Some(chat) = self.chats.get(chat_id) { + self.search_index.rebuild_chat(chat_id, chat); + } else { + self.search_index.remove_chat(chat_id); + } + } + + pub fn rebuild_group_search(&mut self, group_id: &GroupId) { + let our_node = our().node.clone(); + if let Some(group) = self.groups.get(group_id) { + self.search_index + .rebuild_group(group_id, group, &our_node); + } else { + self.search_index.remove_group(group_id); + } + } + + fn ensure_routing_defaults_for_all(&mut self) { + for (group_id, group) in self.groups.iter_mut() { + if group.routing.hub_topic.is_empty() || group.routing.subscriber_topic.is_empty() { + group.routing = GroupRoutingConfig::for_group(group_id); + } + } + } + + pub(crate) fn require_hub_access( + &self, + group_id: &GroupId, + node_id: &NodeId, + ) -> Result<(), String> { + let group = self + .groups + .get(group_id) + .ok_or_else(|| "group not found".to_string())?; + let Some(whitelist) = self.pubsub.whitelist(group_id) else { + return Err("whitelist missing".into()); + }; + if group.routing.hub_topic.is_empty() { + return Err("hub topic unavailable".into()); + } + let topic = BrokerTopicId::new(group.routing.hub_topic.clone()); + let node = BrokerNodeId::new(node_id.clone()); + if whitelist + .publish_scope(&node, &topic, SystemTime::now()) + .is_some() + { + Ok(()) + } else { + Err(format!( + "node {} lacks publish access for group {}", + node_id, group_id + )) + } + } + + pub(crate) fn require_hub_subscription( + &self, + group_id: &GroupId, + node_id: &NodeId, + ) -> Result<(), String> { + let group = self + .groups + .get(group_id) + .ok_or_else(|| "group not found".to_string())?; + let Some(whitelist) = self.pubsub.whitelist(group_id) else { + return Err("whitelist missing".into()); + }; + if group.routing.hub_topic.is_empty() { + return Err("hub topic unavailable".into()); + } + let topic = BrokerTopicId::new(group.routing.hub_topic.clone()); + let node = BrokerNodeId::new(node_id.clone()); + if whitelist + .subscribe_scope(&node, &topic, SystemTime::now()) + .is_some() + { + Ok(()) + } else { + Err(format!( + "node {} lacks hub subscription for group {}", + node_id, group_id + )) + } + } + + pub(crate) fn require_subscriber_access( + &self, + group_id: &GroupId, + node_id: &NodeId, + ) -> Result<(), String> { + let group = self + .groups + .get(group_id) + .ok_or_else(|| "group not found".to_string())?; + let Some(whitelist) = self.pubsub.whitelist(group_id) else { + return Err("whitelist missing".into()); + }; + if group.routing.subscriber_topic.is_empty() { + return Err("subscriber topic unavailable".into()); + } + let topic = BrokerTopicId::new(group.routing.subscriber_topic.clone()); + let node = BrokerNodeId::new(node_id.clone()); + if whitelist + .subscribe_scope(&node, &topic, SystemTime::now()) + .is_some() + { + Ok(()) + } else { + Err(format!( + "node {} lacks subscribe access for group {}", + node_id, group_id + )) + } + } + + pub(crate) fn require_group_permission( + &self, + group_id: &GroupId, + node_id: &NodeId, + permission: u64, + ) -> Result<(), String> { + let group = self + .groups + .get(group_id) + .ok_or_else(|| "group not found".to_string())?; + let member = group + .members + .get(node_id) + .ok_or_else(|| format!("{} is not a member of {}", node_id, group_id))?; + if member.status != MembershipStatus::Active { + return Err(format!( + "member {} is not active in group {}", + node_id, group_id + )); + } + let role = group.roles.get(&member.role_id).ok_or_else(|| { + format!( + "role {} for {} missing in group {}", + member.role_id, node_id, group_id + ) + })?; + if role.permissions.contains(permission) { + Ok(()) + } else { + Err(format!( + "node {} lacks required permission in group {}", + node_id, group_id + )) + } + } + + pub fn publish_group_delta(&mut self, group_id: &GroupId, update_payload: &str) { + let local_node = our().node.clone(); + if let Err(err) = self.require_hub_access(group_id, &local_node) { + crate::log_debug!( + "[CRDT][{}] skip publish: node {} lacks hub access ({})", + group_id, local_node, err + ); + self.replication_metrics.acl_skips = + self.replication_metrics.acl_skips.saturating_add(1); + return; + } + let (hub_topic, sub_topic) = self + .groups + .get(group_id) + .map(|g| { + ( + g.routing.hub_topic.clone(), + g.routing.subscriber_topic.clone(), + ) + }) + .unwrap_or_default(); + let acl_version = self.pubsub.whitelist(group_id).map(|w| w.version()); + if !hub_topic.is_empty() { + self.publish_broker_message( + &hub_topic, + update_payload, + acl_version, + ReplicationKind::PushDelta, + ); + } + if !sub_topic.is_empty() { + self.publish_broker_message( + &sub_topic, + update_payload, + acl_version, + ReplicationKind::PushDelta, + ); + } + } + + pub fn groups(&self) -> &HashMap { + &self.groups + } + + pub fn groups_mut(&mut self) -> &mut HashMap { + &mut self.groups + } + + pub fn group(&self, group_id: &GroupId) -> Option<&Group> { + self.groups.get(group_id) + } + + pub fn group_mut(&mut self, group_id: &GroupId) -> Option<&mut Group> { + self.groups.get_mut(group_id) + } + + pub fn upsert_group(&mut self, group_id: GroupId, group: Group) -> Option { + self.groups.insert(group_id, group) + } + + pub fn remove_group(&mut self, group_id: &GroupId) -> Option { + let removed = self.groups.remove(group_id); + self.group_doc_managers.remove(group_id); + self.groups_pending_bootstrap.remove(group_id); + self.membership_rule_cache.remove(group_id); + self.pubsub.remove_group(group_id); + removed + } + + fn should_seed_group_doc(&self, group: &Group) -> bool { + group + .metadata + .as_ref() + .map(|meta| meta.creator_id == our().node) + .unwrap_or(false) + } + + pub fn group_needs_bootstrap(&self, group_id: &GroupId) -> bool { + let needs = + self.groups_pending_bootstrap.contains(group_id) || !self.groups.contains_key(group_id); + if needs { + crate::log_debug!( + "[BOOT] group_needs_bootstrap group_id={} pending_set_contains={} has_group={}", + group_id, + self.groups_pending_bootstrap.contains(group_id), + self.groups.contains_key(group_id) + ); + } + needs + } + + pub(crate) fn refresh_bootstrap_flags(&mut self) { + let ready: Vec = self + .groups_pending_bootstrap + .iter() + .filter(|gid| { + let has_local_membership = self + .groups + .get(*gid) + .and_then(|g| g.members.get(&our().node)) + .map(|m| m.status == MembershipStatus::Active) + .unwrap_or(false); + let acl_ready = self.local_group_acl_ready(gid); + has_local_membership || acl_ready + }) + .cloned() + .collect(); + for gid in ready { + crate::log_debug!( + "[BOOT] clearing pending_bootstrap for {} (acl_ready={} local_member_active={})", + gid, + self.local_group_acl_ready(&gid), + self.groups + .get(&gid) + .and_then(|g| g.members.get(&our().node)) + .map(|m| m.status == MembershipStatus::Active) + .unwrap_or(false) + ); + self.groups_pending_bootstrap.remove(&gid); + } + } + + pub fn mark_group_bootstrapped(&mut self, group_id: &GroupId) { + crate::log_debug!( + "[BOOT] mark_group_bootstrapped group_id={} pending_before={}", + group_id, + self.groups_pending_bootstrap.contains(group_id) + ); + self.groups_pending_bootstrap.remove(group_id); + } + + pub fn ensure_group_doc_manager( + &mut self, + group_id: &GroupId, + ) -> Result<&mut GroupCrdtManager, CommitteeError> { + if !self.group_doc_managers.contains_key(group_id) { + // If we don't have the group yet, create an empty doc so the first + // incoming snapshot can populate it without being merged with a + // default-initialised state. + let manager = if let Some(group) = self.groups.get(group_id) { + GroupCrdtManager::from_group(group_id, group)? + } else { + GroupCrdtManager::from_empty(group_id)? + }; + self.group_doc_managers.insert(group_id.clone(), manager); + } + + Ok(self + .group_doc_managers + .get_mut(group_id) + .expect("group manager initialised")) + } + + pub fn commit_group_crdt(&mut self, group_id: &GroupId) -> Result<(), CommitteeError> { + if self.group_needs_bootstrap(group_id) { + return Err(CommitteeError::Observer(format!( + "group {} requires bootstrap before CRDT commit", + group_id + ))); + } + + let group = self.groups.get(group_id).ok_or_else(|| { + CommitteeError::Observer(format!("missing group {} for CRDT commit", group_id)) + })?; + let snapshot: crate::GroupDocState = (group_id, group).into(); + let manager = self.ensure_group_doc_manager(group_id)?; + manager.refresh_with_snapshot(snapshot)?; + let state_vector = { + let doc = manager.doc(); + let state_vector = doc.state_vector(); + crate::log_crdt_event(doc.id(), "commit_group_crdt", &state_vector, None); + state_vector + }; + manager.set_last_state_vector(state_vector.clone()); + self.update_local_hub_sync_state(group_id, &state_vector); + // Enqueue per-peer fanout BEFORE rebuilding whitelist, so that members + // who are being removed can still push their final update while they + // still have permissions in the old whitelist. + self.enqueue_replication_pushes(group_id, state_vector.encode_v1()); + // Rebuild whitelist AFTER enqueueing replication, so membership changes + // can propagate before the member loses publish permissions. + let group = self.groups.get(group_id).ok_or_else(|| { + CommitteeError::Observer(format!("missing group {} for whitelist rebuild", group_id)) + })?; + self.pubsub.rebuild_group(group_id, group); + // Notify browser clients so they can refresh group state without polling. + self.broadcast_ws_message(&WsServerMessage::GroupUpdate { + group_id: group_id.clone(), + }); + Ok(()) + } + + pub fn commit_group_crdt_or_log(&mut self, group_id: &GroupId, context: &str) { + if let Err(err) = self.commit_group_crdt(group_id) { + crate::log_debug!( + "Failed to commit group CRDT state (group={} context={}): {:?}", + group_id, context, err + ); + } + } + + pub fn create_group_state( + &mut self, + mut req: CreateGroupReq, + ) -> Result { + let group_id = req.group_id.take().unwrap_or_else(generate_group_id); + if self.groups.contains_key(&group_id) { + return Err("Group already exists".to_string()); + } + + let now = current_timestamp(); + let creator = our().node.clone(); + let mut counters = crate::crdt::GroupCounters::default(); + let root_thread_id = counters.next_thread_id(&group_id); + + let default_role_id = format!("{group_id}:member"); + let owner_role_id = format!("{group_id}:owner"); + let visibility = req.visibility.unwrap_or(GroupVisibility::Private); + + let metadata = GroupMetadata::new( + req.name, + req.description, + req.avatar, + creator.clone(), + now, + now, + visibility, + default_role_id.clone(), + root_thread_id.clone(), + ); + + let mut group = Group::new(metadata); + group.routing = GroupRoutingConfig::for_group(&group_id); + group.counters = counters; + + let owner_role = Role::new( + owner_role_id.clone(), + "Owner", + crate::crdt::GroupPermissions::all(), + GroupTier::Hub, + ); + let mut member_permissions = crate::crdt::GroupPermissions::empty(); + member_permissions.insert(crate::crdt::GroupPermissions::SEND_MESSAGES); + member_permissions.insert(crate::crdt::GroupPermissions::CREATE_THREADS); + let member_role = Role::new( + default_role_id.clone(), + req.default_role_label + .unwrap_or_else(|| "Member".to_string()), + member_permissions, + GroupTier::Subscriber, + ); + + group.roles.insert(owner_role_id.clone(), owner_role); + group.roles.insert(default_role_id.clone(), member_role); + + group.members.insert( + creator.clone(), + GroupMember::new( + creator.clone(), + owner_role_id, + MembershipStatus::Active, + now, + ), + ); + group.hubs.active.insert(creator.clone()); + group.hubs.upsert_sync( + creator.clone(), + HubSyncState { + last_seen_ts: now, + ..HubSyncState::default() + }, + ); + group.subscribers.entries.insert( + creator.clone(), + SubscriberSyncState { + last_state_vector: None, + last_snapshot_digest: None, + last_seen_ts: now, + }, + ); + group.delivery.hub_cursors.insert( + creator.clone(), + crate::crdt::DeliveryCursor { + queue_id: group.routing.hub_topic.clone(), + last_offset: 0, + updated_at: now, + }, + ); + group.delivery.subscriber_cursors.insert( + creator.clone(), + crate::crdt::DeliveryCursor { + queue_id: group.routing.subscriber_topic.clone(), + last_offset: 0, + updated_at: now, + }, + ); + + group.membership_rules = ensure_membership_rules(req.membership_rules, &creator); + + let mut root_thread = Thread::new( + root_thread_id.clone(), + group_id.clone(), + 0, + ThreadParentRef::Root(group_id.clone()), + now, + creator.clone(), + ); + root_thread.title = req.root_thread_title; + root_thread.summary.last_activity = now; + root_thread.summary.last_sender = Some(creator); + group.threads.insert(root_thread_id, root_thread); + + self.groups.insert(group_id.clone(), group); + self.mark_group_bootstrapped(&group_id); + self.commit_group_crdt_or_log(&group_id, "create_group"); + self.rebuild_group_search(&group_id); + + Ok(CreateGroupRes { group_id }) + } + + pub fn list_groups_state(&self) -> ListGroupsRes { + let caller = our().node; + let groups = self + .groups + .iter() + .filter(|(_group_id, group)| { + // Only include groups where the caller is an active member + group + .members + .get(&caller) + .map(|m| m.status == MembershipStatus::Active) + .unwrap_or(false) + }) + .map(|(group_id, group)| GroupSummary { + group_id: group_id.clone(), + metadata: group.metadata.clone(), + member_count: group.members.len(), + thread_count: group.threads.len(), + unread_count: self.group_unread.get(group_id).copied().unwrap_or(0), + notify: self.group_notify.get(group_id).copied().unwrap_or(true), + }) + .collect(); + ListGroupsRes { groups } + } + + pub fn get_group_state(&self, req: GetGroupReq) -> GetGroupRes { + let group = self.groups.get(&req.group_id).cloned(); + if let Some(ref g) = group { + for msg in g.messages.values() { + if !msg.reactions.is_empty() { + crate::log_debug!( + "[GET_GROUP] msg_id={} has {} reactions: {:?}", + msg.message_id, + msg.reactions.len(), + msg.reactions.iter().map(|r| &r.emoji).collect::>() + ); + } + } + } + GetGroupRes { group } + } + + pub fn create_group_thread_state( + &mut self, + mut req: CreateGroupThreadReq, + ) -> Result { + self.require_group_permission( + &req.group_id, + &our().node, + crate::crdt::GroupPermissions::CREATE_THREADS, + ) + .map_err(|err| format!("cannot create thread: {}", err))?; + self.require_subscriber_access(&req.group_id, &our().node) + .map_err(|err| format!("cannot create thread: {}", err))?; + + let thread_id = self.next_group_thread_id(&req.group_id)?; + let now = current_timestamp(); + let creator = our().node.clone(); + + { + let group = self + .groups + .get_mut(&req.group_id) + .ok_or_else(|| "Group not found".to_string())?; + + let root_thread_id = group_root_thread_id(group) + .ok_or_else(|| "Group missing root thread".to_string())?; + + let (parent_ref, depth, parent_child) = + if let Some(parent_id) = req.parent_thread_id.take() { + let parent = group + .threads + .get(&parent_id) + .ok_or_else(|| "Parent thread not found".to_string())?; + ( + ThreadParentRef::Thread(parent_id.clone()), + parent.depth + 1, + Some(parent_id.clone()), + ) + } else { + ( + ThreadParentRef::Root(root_thread_id.clone()), + 0, + Some(root_thread_id.clone()), + ) + }; + + // Validate and attach root message if provided. + let root_message_id = if let Some(root_id) = req.root_message_id.take() { + let msg = group + .messages + .get(&root_id) + .ok_or_else(|| "Root message not found".to_string())?; + // Ensure the message belongs to the chosen parent thread (or root). + if let Some(parent_id) = &parent_child { + if &msg.thread_id != parent_id { + return Err("Root message does not belong to parent thread".to_string()); + } + } + Some(root_id) + } else { + None + }; + + let mut thread = Thread::new( + thread_id.clone(), + req.group_id.clone(), + depth, + parent_ref, + now, + creator, + ); + thread.title = req.title.take(); + thread.summary.last_activity = now; + thread.root_message_id = root_message_id; + + group.threads.insert(thread_id.clone(), thread); + if let Some(parent_id) = parent_child { + if let Some(parent) = group.threads.get_mut(&parent_id) { + parent.child_threads.push(thread_id.clone()); + } + } + if let Some(meta) = group.metadata.as_mut() { + // Ensure updated_at advances even if called within the same second. + let mut ts = now; + if meta.updated_at >= ts { + ts = meta.updated_at.saturating_add(1); + } + meta.updated_at = ts; + } + } + self.commit_group_crdt_or_log(&req.group_id, "create_group_thread"); + Ok(CreateGroupThreadRes { thread_id }) + } +} diff --git a/hyperdrive/packages/homepage/chat/src/ws.rs b/hyperdrive/packages/homepage/chat/src/ws.rs new file mode 100644 index 000000000..53f3ab02a --- /dev/null +++ b/hyperdrive/packages/homepage/chat/src/ws.rs @@ -0,0 +1,241 @@ +use crate::{ + safe_update_message_status, ChatMessage, ChatState, MessageStatus, MessageType, + WsClientMessage, WsServerMessage, +}; +use hyperware_process_lib::{ + http::server::{HttpServerRequest, WsMessageType}, + Address, LazyLoadBlob, Request, +}; +use serde_json; + +impl ChatState { + pub(crate) fn handle_client_message(&mut self, channel_id: u32, msg: WsClientMessage) { + match msg { + WsClientMessage::SendMessage { + chat_id, + content, + reply_to, + } => { + if let Err(err) = + self.send_message_internal(&chat_id, content, reply_to, Some(channel_id)) + { + crate::log_debug!("Failed to send message via WS: {}", err); + } + } + WsClientMessage::Ack { message_id } => { + // Update message status + for chat in self.chats.values_mut() { + if let Some(message) = chat.messages.iter_mut().find(|m| m.id == message_id) { + message.status = + safe_update_message_status(&message.status, MessageStatus::Delivered); + break; + } + } + } + WsClientMessage::MarkRead { chat_id } => { + if let Some(chat) = self.chats.get_mut(&chat_id) { + chat.unread_count = 0; + } + } + WsClientMessage::MarkGroupRead { group_id } => { + self.group_unread.insert(group_id, 0); + } + WsClientMessage::UpdateStatus { status } => { + // Track whether this connection is active (user viewing the page) + if status == "active" { + self.active_connections.insert(channel_id); + } else if status == "inactive" { + self.active_connections.remove(&channel_id); + } + + if let Some(node) = self.ws_connections.get(&channel_id) { + let msg = WsServerMessage::StatusUpdate { + node: node.clone(), + status, + }; + self.broadcast_ws_message(&msg); + } + } + WsClientMessage::Heartbeat => { + let msg = WsServerMessage::Heartbeat; + self.push_ws_message(channel_id, &msg); + } + _ => { + // Other message types not handled in node-to-node + } + } + } + + pub(crate) fn handle_browser_message(&mut self, channel_id: u32, msg: WsClientMessage) { + match msg { + WsClientMessage::AuthWithKey { chat_key } => { + if let Some(key_data) = self.chat_keys.get(&chat_key) { + if !key_data.is_revoked { + // Store connection + self.browser_connections + .insert(chat_key.clone(), channel_id); + + // Get chat history + let history = self + .chats + .get(&key_data.chat_id) + .map(|chat| chat.messages.clone()) + .unwrap_or_default(); + + let msg = WsServerMessage::AuthSuccess { + chat_id: key_data.chat_id.clone(), + history, + }; + self.push_ws_message(channel_id, &msg); + } else { + let msg = WsServerMessage::AuthFailed { + reason: "Chat key has been revoked".to_string(), + }; + self.push_ws_message(channel_id, &msg); + } + } else { + let msg = WsServerMessage::AuthFailed { + reason: "Invalid chat key".to_string(), + }; + self.push_ws_message(channel_id, &msg); + } + } + WsClientMessage::BrowserMessage { content } => { + // Find chat key for this connection + if let Some((chat_key, _)) = self + .browser_connections + .iter() + .find(|(_, &ch)| ch == channel_id) + { + if let Some(key_data) = self.chat_keys.get(chat_key).cloned() { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let chat_id = key_data.chat_id.clone(); + self.get_or_create_chat( + &chat_id, + timestamp, + Some(key_data.user_name.clone()), + None, + ); + + let mut message = ChatMessage { + id: format!("{}:{}", timestamp, rand::random::()), + sender: key_data.user_name.clone(), + content, + timestamp, + sequence: None, + status: MessageStatus::Sent, + reply_to: None, + reactions: Vec::new(), + message_type: MessageType::Text, + file_info: None, + }; + + self.assign_sequence_to_message(&chat_id, &mut message); + + self.get_or_create_chat( + &chat_id, + timestamp, + Some(key_data.user_name.clone()), + None, + ); + { + let chat = self.get_or_create_chat( + &chat_id, + timestamp, + Some(key_data.user_name.clone()), + None, + ); + chat.messages.push(message.clone()); + chat.last_activity = timestamp; + chat.unread_count += 1; + } + self.rebuild_chat_search(&chat_id); + + // Send message to all participants + let msg = WsServerMessage::NewMessage(message); + self.push_ws_message(channel_id, &msg); + } + } + } + WsClientMessage::Heartbeat => { + let msg = WsServerMessage::Heartbeat; + self.push_ws_message(channel_id, &msg); + } + _ => {} + } + } + + /// Push a message to a WebSocket channel. Returns true if successful, false if channel not found. + pub(crate) fn push_ws_message(&self, channel_id: u32, message: &WsServerMessage) -> bool { + let bytes = match serde_json::to_vec(message) { + Ok(bytes) => bytes, + Err(err) => { + crate::log_debug!("Failed to serialize WS message: {:?}", err); + return false; + } + }; + + let request = Request::to(Address::new( + "our", + ("http-server", "distro", "sys"), + )) + .body( + serde_json::to_vec(&HttpServerRequest::WebSocketPush { + channel_id, + message_type: WsMessageType::Text, + }) + .unwrap(), + ) + .blob(LazyLoadBlob { + mime: Some("application/json".to_string()), + bytes, + }); + + // Send and await response to detect stale channels + match request.send_and_await_response(2) { + Ok(Ok(response)) => { + // Check if response body contains "WsChannelNotFound" + if let Ok(body_str) = String::from_utf8(response.body().to_vec()) { + if body_str.contains("WsChannelNotFound") { + crate::log_debug!("[WS_DEBUG] Channel {} not found, marking for removal", channel_id); + return false; + } + } + true + } + Ok(Err(send_err)) => { + crate::log_debug!("[WS_DEBUG] Send error for channel {}: {:?}", channel_id, send_err); + false + } + Err(err) => { + crate::log_debug!("[WS_DEBUG] Failed to push to channel {}: {:?}", channel_id, err); + false + } + } + } + + /// Broadcast a message to all WebSocket connections, removing stale channels. + pub(crate) fn broadcast_ws_message(&mut self, message: &WsServerMessage) { + let channels: Vec = self.ws_connections.keys().cloned().collect(); + crate::log_debug!("[WS_DEBUG] broadcast_ws_message: ws_connections has {} channels: {:?}", channels.len(), channels); + + let mut stale_channels = Vec::new(); + for channel_id in channels { + if !self.push_ws_message(channel_id, message) { + stale_channels.push(channel_id); + } + } + + // Clean up stale channels + for channel_id in stale_channels { + crate::log_debug!("[WS_DEBUG] Removing stale channel {} from ws_connections", channel_id); + self.ws_connections.remove(&channel_id); + self.browser_connections.retain(|_, &mut v| v != channel_id); + self.active_connections.remove(&channel_id); + } + } +} diff --git a/hyperdrive/packages/homepage/chat/tests/debug_membership_payload.rs b/hyperdrive/packages/homepage/chat/tests/debug_membership_payload.rs new file mode 100644 index 000000000..e672c54d3 --- /dev/null +++ b/hyperdrive/packages/homepage/chat/tests/debug_membership_payload.rs @@ -0,0 +1,29 @@ +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use chat::GroupDocState; +use hyperware_crdt::CommitteeDoc; + +#[test] +fn decode_membership_acl_snapshot_payload() { + let payload = "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"; + + let update_bytes = STANDARD.decode(payload).expect("base64 decode"); + assert_eq!(update_bytes.len(), 2648); + + let mut doc = CommitteeDoc::::empty( + "chat:group:group:fake.os:1764248720:4164775213".to_string(), + ) + .expect("doc init"); + doc.apply_update(&update_bytes).expect("apply update"); + + let state = doc.read_state().expect("state"); + let member_count = state.group.members.len(); + let role_count = state.group.roles.len(); + let sub_count = state.group.subscribers.entries.len(); + assert!( + member_count > 0, + "members empty (roles={}, subs={})", + role_count, + sub_count + ); +} diff --git a/hyperdrive/packages/homepage/homepage/Cargo.toml b/hyperdrive/packages/homepage/homepage/Cargo.toml index ae4abcec9..80aa734ec 100644 --- a/hyperdrive/packages/homepage/homepage/Cargo.toml +++ b/hyperdrive/packages/homepage/homepage/Cargo.toml @@ -9,11 +9,14 @@ simulation-mode = [] [dependencies] anyhow = "1.0" bincode = "1.3.3" -hyperware_process_lib = "2.1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" wit-bindgen = "0.42.1" +[dependencies.hyperware_process_lib] +features = ["hyperapp"] +version = "3.0.0" + [lib] crate-type = ["cdylib"] diff --git a/hyperdrive/packages/homepage/homepage/src/lib.rs b/hyperdrive/packages/homepage/homepage/src/lib.rs index eb1af9d58..a2d2a265e 100644 --- a/hyperdrive/packages/homepage/homepage/src/lib.rs +++ b/hyperdrive/packages/homepage/homepage/src/lib.rs @@ -384,8 +384,18 @@ fn init(our: Address) { }; // Handle WebSocket events match &request { - http::server::HttpServerRequest::WebSocketOpen { path, channel_id } => { - http_server.handle_websocket_open(path, *channel_id); + http::server::HttpServerRequest::WebSocketOpen { + path, + channel_id, + source_socket_addr, + forwarded_for, + } => { + http_server.handle_websocket_open( + path, + *channel_id, + source_socket_addr.clone(), + forwarded_for.clone(), + ); continue; } http::server::HttpServerRequest::WebSocketClose(channel_id) => { diff --git a/hyperdrive/packages/homepage/pkg/manifest.json b/hyperdrive/packages/homepage/pkg/manifest.json index 6d584e263..4bd654ff0 100644 --- a/hyperdrive/packages/homepage/pkg/manifest.json +++ b/hyperdrive/packages/homepage/pkg/manifest.json @@ -14,5 +14,35 @@ "notifications:distro:sys" ], "public": false + }, + { + "process_name": "chat", + "process_wasm_path": "/chat.wasm", + "on_exit": "Restart", + "request_networking": true, + "request_capabilities": [ + "homepage:homepage:sys", + "http-client:distro:sys", + "http-server:distro:sys", + "kv:distro:sys", + "notifications:distro:sys", + "spider:spider:sys", + "terminal:terminal:sys", + "timer:distro:sys", + "vfs:distro:sys" + ], + "grant_capabilities": [ + "homepage:homepage:sys", + "http-client:distro:sys", + "http-server:distro:sys", + "kv:distro:sys", + "notifications:distro:sys", + "spider:spider:sys", + "terminal:terminal:sys", + "timer:distro:sys", + "vfs:distro:sys", + "tester:tester:sys" + ], + "public": false } ] diff --git a/hyperdrive/packages/homepage/ui/src/components/Home/components/AppContainer.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/AppContainer.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/Home/components/AppContainer.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/AppContainer.tsx diff --git a/hyperdrive/packages/homepage/ui/src/components/Home/components/AppDrawer.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/AppDrawer.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/Home/components/AppDrawer.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/AppDrawer.tsx diff --git a/hyperdrive/packages/homepage/ui/src/components/Home/components/AppIcon.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/AppIcon.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/Home/components/AppIcon.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/AppIcon.tsx diff --git a/hyperdrive/packages/homepage/ui/src/components/Home/components/Draggable.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/Draggable.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/Home/components/Draggable.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/Draggable.tsx diff --git a/hyperdrive/packages/homepage/ui/src/components/Home/components/HomeScreen.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/HomeScreen.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/Home/components/HomeScreen.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/HomeScreen.tsx diff --git a/hyperdrive/packages/homepage/ui/src/components/Home/components/Modal.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/Modal.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/Home/components/Modal.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/Modal.tsx diff --git a/hyperdrive/packages/homepage/ui/src/components/Home/components/NotificationMenu.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/NotificationMenu.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/Home/components/NotificationMenu.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/NotificationMenu.tsx diff --git a/hyperdrive/packages/homepage/ui/src/components/Home/components/OmniButton.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/OmniButton.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/Home/components/OmniButton.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/OmniButton.tsx diff --git a/hyperdrive/packages/homepage/ui/src/components/Home/components/RecentApps.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/RecentApps.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/Home/components/RecentApps.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/RecentApps.tsx diff --git a/hyperdrive/packages/homepage/ui/src/components/Home/components/Widget.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/Widget.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/Home/components/Widget.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/Home/components/Widget.tsx diff --git a/hyperdrive/packages/homepage/ui/src/components/Home/index.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/Home/index.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/Home/index.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/Home/index.tsx diff --git a/hyperdrive/packages/homepage/ui/src/components/Home/styles/animations.css b/hyperdrive/packages/homepage/ui/homepage-src/components/Home/styles/animations.css similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/Home/styles/animations.css rename to hyperdrive/packages/homepage/ui/homepage-src/components/Home/styles/animations.css diff --git a/hyperdrive/packages/homepage/ui/src/components/InstallPrompt.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/InstallPrompt.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/InstallPrompt.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/InstallPrompt.tsx diff --git a/hyperdrive/packages/homepage/ui/src/components/NotificationSettings.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/NotificationSettings.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/NotificationSettings.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/NotificationSettings.tsx diff --git a/hyperdrive/packages/homepage/ui/src/components/UpdateNotification.tsx b/hyperdrive/packages/homepage/ui/homepage-src/components/UpdateNotification.tsx similarity index 100% rename from hyperdrive/packages/homepage/ui/src/components/UpdateNotification.tsx rename to hyperdrive/packages/homepage/ui/homepage-src/components/UpdateNotification.tsx diff --git a/hyperdrive/packages/homepage/ui/homepage-src/index.css b/hyperdrive/packages/homepage/ui/homepage-src/index.css new file mode 100644 index 000000000..183b1156d --- /dev/null +++ b/hyperdrive/packages/homepage/ui/homepage-src/index.css @@ -0,0 +1,594 @@ +@import "tailwindcss"; + +/* Variables */ +:root { + color-scheme: light dark; + --neon-green: #dcff71; + --neon-green-light: #dcff7188; + --neon-green-xlight: #dcff7144; + --iris: #004fff; + --iris-light: #004fff88; + --iris-xlight: #004fff44; + --stone: #353534; + --black: #111111; + --black-light: #11111188; + --black-xlight: #11111111; + --tasteful-dark: var(--black); + --white: #f6f6f6; + --white-light: #f6f6f688; + --white-xlight: #f6f6f611; + --magenta: #bf1363; + --orange: #dd6e42; + --off-white: var(--white); + --off-black: var(--stone); + --adaptive-gray: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); + + --primary-color: var(--neon-green); + --primary-light: var(--neon-green-light); + --primary-xlight: var(--neon-green-xlight); + --secondary-color: var(--iris); + --secondary-light: var(--iris-light); + --secondary-xlight: var(--iris-xlight); + --tertiary-color: var(--orange); + --quaternary-color: var(--magenta); + + --link-color: light-dark(var(--secondary-color), var(--primary-color)); + + --font-family-main: 'Neue Haas Grotesk', monospace; + + /* Add modern CSS variables */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --transition-fast: 150ms ease; + --transition-normal: 250ms ease; + --button-border-width: 2px; + + /* Motion design system - durations */ + --duration-instant: 100ms; + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-medium: 300ms; + --duration-slow: 400ms; + --duration-emphasis: 500ms; + + /* Motion design system - easing curves */ + --ease-out: cubic-bezier(0.0, 0.0, 0.2, 1); + --ease-in: cubic-bezier(0.4, 0.0, 1, 1); + --ease-in-out: cubic-bezier(0.4, 0.0, 0.2, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55); +} + + +@theme { + --color-iris: var(--iris); + --color-iris-light: var(--iris-light); + --color-iris-xlight: var(--iris-xlight); + --color-neon: var(--neon-green); + --color-neon-light: var(--neon-green-light); + --color-neon-xlight: var(--neon-green-xlight); + --color-stone: var(--stone); + --color-black: var(--black); + --color-black-light: var(--black-light); +} + +@font-face { + font-family: 'chaneyextended'; + src: url('/chaneyextended.woff2') format('woff2'); +} + +@font-face { + font-family: 'Clash Display'; + src: url('/ClashDisplay-Variable.woff2') format('woff2'); +} + +@font-face { + font-family: 'Neue Haas Grotesk'; + src: url('/NHaasGroteskTXPro-55Rg.woff') format('woff'); + font-weight: 500; +} + +@font-face { + font-family: 'Neue Haas Grotesk'; + src: url('/NHaasGroteskTXPro-75Bd.woff') format('woff'); + font-weight: 700; +} + +html, +body { + font-family: 'Neue Haas Grotesk', sans-serif; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + line-height: 1.2; + margin: 0; + font-family: 'Clash Display', 'Neue Haas Grotesk', sans-serif; +} + +h1 { + font-size: 2rem; +} + +h2 { + font-size: 1.5rem; +} + +h3 { + font-size: 1.25rem; +} + +h4 { + font-size: 1.125rem; +} + +h6 { + font-size: 0.875rem; +} + +button, +.button { + @apply cursor-pointer flex items-center gap-2 justify-center bg-neon text-black px-4 py-2 rounded-md hover:bg-black hover:text-neon transition-colors duration-200 hover:outline-2; + + &.clear { + @apply bg-transparent text-black dark:text-white border-none hover:bg-black hover:text-neon; + } + + &.thin { + @apply px-2 py-1; + } + + &:disabled { + @apply opacity-50 cursor-not-allowed; + } +} + +input { + @apply bg-black/10 dark:bg-white/10 border-none outline-none px-4 py-2 rounded-md; + +} + +input, +textarea, +select { + transition: all var(--transition-fast); +} + +input:focus, +textarea:focus, +select:focus { + @apply outline-2 outline-neon outline-offset-2; + animation: shine 0.4s ease-out; + background-size: 200% 100%; + background-image: linear-gradient(45deg, transparent 50%, var(--primary-xlight) 51%, var(--primary-light) 52%, transparent); +} + +@keyframes shine { + 0% { + background-position: 200% 0%; + } + + 100% { + background-position: 0% 0%; + } +} + +.display { + font-family: 'chaneyextended', sans-serif; +} + +.prose { + font-family: 'Neue Haas Grotesk', sans-serif; +} + +.clash { + font-family: 'Clash Display', sans-serif; +} + +pre { + white-space: pre-wrap; +} + +a { + @apply text-iris dark:text-neon hover:underline; +} + + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +@keyframes shake { + + 10%, + 90% { + transform: translate3d(-1px, 0, 0); + } + + 20%, + 80% { + transform: translate3d(2px, 0, 0); + } + + 30%, + 50%, + 70% { + transform: translate3d(-4px, 0, 0); + } + + 40%, + 60% { + transform: translate3d(4px, 0, 0); + } +} + +@keyframes pulse { + 0% { + opacity: 1; + } + + 50% { + opacity: 0.6; + } + + 100% { + opacity: 1; + } +} + +header { + background-color: light-dark(var(--white), var(--tasteful-dark)); + border-color: light-dark(var(--tasteful-dark), var(--off-white)); +} + +header { + width: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-end; + padding: 1em; + justify-content: space-between; + align-items: center; + border: 1px solid light-dark(var(--tasteful-dark), var(--off-white)); + border-bottom: none; + border-radius: 1em 1em 0px 0px; +} + +header h2 { + flex-grow: 100; +} + +header button { + margin-left: 4px; +} + +@media (prefers-color-scheme: dark) {} + +.no-ui { + position: absolute; + bottom: 0; + left: 0; +} + +.widget { + display: flex; + flex-direction: column; + color: light-dark(var(--tasteful-dark), var(--off-white)); + background-color: light-dark(var(--off-white), var(--tasteful-dark)); + /* border-radius: 10px; */ + text-align: center; + position: relative; + width: 100%; + height: 400px; + overflow: hidden; +} + +.widget iframe { + flex-grow: 1; + border: none; + width: 100%; + height: 100%; +} + +.widget .bottom-bar { + display: none; + position: absolute; + bottom: 0; + border-top: 1px solid light-dark(black, white); + background-color: var(--secondary-color); + width: 100%; + padding: 2px; + flex-direction: row; + justify-content: space-between; + color: var(--off-white); + border-color: var(--off-white); +} + +[id^="hide-widget-"] { + cursor: pointer; +} + +[id^="hide-widget-"]:hover { + text-decoration: underline; +} + +.widget:hover .bottom-bar { + display: flex; +} + +.widget .bottom-bar p { + font-size: 0.8em; + cursor: default; + color: var(--off-white); +} + +.widget-wrapper { + border: 1px solid light-dark(var(--off-white), var(--tasteful-dark)); +} + +footer { + text-align: center; + max-height: 100vh; + max-width: 100vw; +} + +.apps-grid { + display: grid; + width: 100%; + color: var(--off-white); + border-top: 1px solid light-dark(rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.2)); +} + +/* Add side borders to the app grid based on viewport edges */ +.apps-grid { + position: relative; + box-sizing: border-box; +} + +/* Remove unwanted vertical border pseudo-elements */ + +/* Make sure the border extends to the full expanded height */ +.apps-grid.expanded::before, +.apps-grid.expanded::after { + height: 100%; +} + +.apps-grid.apps-count-3 { + grid-template-columns: repeat(3, 1fr); +} + +.expand-button { + width: 100%; + background-color: transparent; + border-radius: 0 0 1em 1em; + border: 0.5px solid rgba(255, 255, 255, 0.2); + padding: 1em; + color: var(--white); + box-sizing: border-box; + max-width: 100%; +} + +@media (max-width: 1024px) { + .apps-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 960px) { + header { + flex-shrink: 0; + padding: 0.75em; + gap: 0.5em; + border-radius: 0; + } + + header h2 { + font-size: 1.25em; + } + + .apps-grid { + max-height: 50vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + + .apps-grid.expanded { + display: flex; + flex-direction: column; + } + + .expand-button { + height: 44px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1em; + touch-action: manipulation; + } + + .drag-handle { + display: none; + } + + .app-wrapper, + .widget-wrapper { + touch-action: manipulation; + } + + /* Add smooth scrolling for iOS momentum scroll */ + *:where([class*="container"], [class*="grid"]) { + -webkit-overflow-scrolling: touch; + } + + .widget { + width: 100%; + height: auto; + min-height: 300px; + margin: 0 0 1em 0; + border-radius: 12px; + } + + .widget .bottom-bar { + display: flex; + position: relative; + padding: 8px; + height: 40px; + } +} + +@media (max-width: 480px) { + .widget { + min-height: 250px; + width: 100%; + } + + header { + flex-direction: column; + align-items: flex-start; + } + + header button { + margin: 4px 0; + } +} + +.app-wrapper { + position: relative; + transition: transform 0.2s ease; + /* Add a border that continues the pane's border */ + border-bottom: 0.5px solid rgba(255, 255, 255, 0.2); + border-right: 0.5px solid rgba(255, 255, 255, 0.2); +} + +/* Remove right border for last app in a row */ +.app-wrapper.last-in-row { + border-right: none; +} + +.widget-wrapper { + position: relative; + transition: transform 0.2s ease; +} + +.app-wrapper:hover .drag-handle { + opacity: 1; +} + +.widget-wrapper:hover .drag-handle { + opacity: 1; +} + +.drag-handle { + position: absolute; + top: 5px; + right: 5px; + cursor: move; + opacity: 0; + transition: opacity 0.2s ease; + color: var(--white); + font-size: 1.2em; + text-shadow: -1px 1px 0px #000; +} + +.dragging { + opacity: 0.5; +} + +.drag-over { + transform: translateY(5px); +} + +.modal .widget-settings { + display: flex; + flex-direction: column; + gap: 0.5em; +} + + +.widget-settings button { + margin-top: 0.5em; +} + +.empty-state { + height: 400px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.empty-state p { + text-align: center; + font-size: 14px; +} + +/* Dock hover effects */ +.dock-icon { + transition: transform var(--duration-fast) var(--ease-spring), + box-shadow var(--duration-fast) var(--ease-out); +} + +.dock-icon:hover { + transform: translateY(-6px) scale(1.08); + box-shadow: 0 10px 20px -6px rgba(0, 0, 0, 0.25); +} + +.dock-icon:active { + transform: translateY(-2px) scale(0.98); +} + +/* Staggered grid entrance animation - fast for snappy feel */ +.animate-grid-enter { + animation: grid-item-enter var(--duration-fast) var(--ease-out) both; + animation-delay: calc(var(--item-index, 0) * 12ms); +} + +/* Modal animations */ +.animate-modal-backdrop { + animation: backdrop-enter var(--duration-fast) var(--ease-out) both; +} + +.animate-modal-content { + animation: modal-enter var(--duration-medium) var(--ease-out) both; + animation-delay: 50ms; +} + +/* OmniButton animations */ +.animate-omni-pulse { + animation: omni-pulse 2.5s ease-in-out infinite; +} + +/* App container animations */ +.animate-app-launch { + animation: app-launch var(--duration-slow) var(--ease-out) both; +} + +.animate-app-close { + animation: app-close var(--duration-normal) var(--ease-in) both; +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/homepage-src/main.tsx b/hyperdrive/packages/homepage/ui/homepage-src/main.tsx new file mode 100644 index 000000000..710e4f992 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/homepage-src/main.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import Home from './components/Home' +import './index.css' +import { useNotificationStore } from './stores/notificationStore' +import { initializePushNotifications } from './utils/pushNotifications' + +// Listen for push notification messages from service worker +if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data && event.data.type === 'PUSH_NOTIFICATION_RECEIVED') { + const notification = event.data.notification; + + // Extract appId and appLabel from data if available + const appId = notification.data?.appId || notification.appId || 'system'; + const appLabel = notification.data?.appLabel || notification.appLabel || 'System'; + + // Add to notification store + useNotificationStore.getState().addNotification({ + appId, + appLabel, + title: notification.title, + body: notification.body, + icon: notification.icon, + }); + } + }); +} + +// Register service worker for PWA +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js') + .then(async (registration) => { + + // Initialize push notifications + await initializePushNotifications(registration); + + // Update permission state in store + if ('Notification' in window) { + useNotificationStore.getState().setPermissionGranted( + Notification.permission === 'granted' + ); + } + + // Check for updates periodically + setInterval(() => { + registration.update(); + }, 60 * 60 * 1000); // Check every hour + }) + .catch((error) => { + console.error('SW registration failed:', error); + }); + }); +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/hyperdrive/packages/homepage/ui/src/stores/appStore.ts b/hyperdrive/packages/homepage/ui/homepage-src/stores/appStore.ts similarity index 100% rename from hyperdrive/packages/homepage/ui/src/stores/appStore.ts rename to hyperdrive/packages/homepage/ui/homepage-src/stores/appStore.ts diff --git a/hyperdrive/packages/homepage/ui/src/stores/navigationStore.ts b/hyperdrive/packages/homepage/ui/homepage-src/stores/navigationStore.ts similarity index 100% rename from hyperdrive/packages/homepage/ui/src/stores/navigationStore.ts rename to hyperdrive/packages/homepage/ui/homepage-src/stores/navigationStore.ts diff --git a/hyperdrive/packages/homepage/ui/src/stores/notificationStore.ts b/hyperdrive/packages/homepage/ui/homepage-src/stores/notificationStore.ts similarity index 100% rename from hyperdrive/packages/homepage/ui/src/stores/notificationStore.ts rename to hyperdrive/packages/homepage/ui/homepage-src/stores/notificationStore.ts diff --git a/hyperdrive/packages/homepage/ui/src/stores/persistenceStore.ts b/hyperdrive/packages/homepage/ui/homepage-src/stores/persistenceStore.ts similarity index 100% rename from hyperdrive/packages/homepage/ui/src/stores/persistenceStore.ts rename to hyperdrive/packages/homepage/ui/homepage-src/stores/persistenceStore.ts diff --git a/hyperdrive/packages/homepage/ui/src/types/app.types.ts b/hyperdrive/packages/homepage/ui/homepage-src/types/app.types.ts similarity index 100% rename from hyperdrive/packages/homepage/ui/src/types/app.types.ts rename to hyperdrive/packages/homepage/ui/homepage-src/types/app.types.ts diff --git a/hyperdrive/packages/homepage/ui/src/types/messages.ts b/hyperdrive/packages/homepage/ui/homepage-src/types/messages.ts similarity index 100% rename from hyperdrive/packages/homepage/ui/src/types/messages.ts rename to hyperdrive/packages/homepage/ui/homepage-src/types/messages.ts diff --git a/hyperdrive/packages/homepage/ui/src/utils/pushNotifications.ts b/hyperdrive/packages/homepage/ui/homepage-src/utils/pushNotifications.ts similarity index 100% rename from hyperdrive/packages/homepage/ui/src/utils/pushNotifications.ts rename to hyperdrive/packages/homepage/ui/homepage-src/utils/pushNotifications.ts diff --git a/hyperdrive/packages/homepage/ui/homepage-src/vite-env.d.ts b/hyperdrive/packages/homepage/ui/homepage-src/vite-env.d.ts new file mode 100644 index 000000000..1e9131bbf --- /dev/null +++ b/hyperdrive/packages/homepage/ui/homepage-src/vite-env.d.ts @@ -0,0 +1,13 @@ +/// + +interface ImportMetaEnv { + readonly VITE_APP_TITLE: string; + readonly REACT_APP_MAINNET_RPC_URL: string; + readonly REACT_APP_SEPOLIA_RPC_URL: string; + readonly VITE_NODE_URL: string; + // Add other environment variables as needed +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/index.html b/hyperdrive/packages/homepage/ui/index.html index c11bebde8..fffdda053 100644 --- a/hyperdrive/packages/homepage/ui/index.html +++ b/hyperdrive/packages/homepage/ui/index.html @@ -43,15 +43,15 @@ + + + +
- - diff --git a/hyperdrive/packages/homepage/ui/package-lock.json b/hyperdrive/packages/homepage/ui/package-lock.json index 53816819d..23bd74401 100644 --- a/hyperdrive/packages/homepage/ui/package-lock.json +++ b/hyperdrive/packages/homepage/ui/package-lock.json @@ -9,15 +9,23 @@ "version": "0.0.0", "dependencies": { "@hello-pangea/dnd": "^16.6.0", + "@hyperware-ai/hw-protocol-watcher": "^1.0.1", "@tailwindcss/vite": "^4.1.11", + "@types/mdast": "^4.0.4", "classnames": "^2.5.1", "dayjs": "^1.11.13", + "fix-webm-duration": "^1.0.6", + "framer-motion": "^12.24.12", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", "react-dom": "^18.2.0", "react-icons": "^5.1.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.23.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.11", + "unist-util-visit": "^5.0.0", "zustand": "^4.5.2" }, "devDependencies": { @@ -1072,6 +1080,12 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@hyperware-ai/hw-protocol-watcher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@hyperware-ai/hw-protocol-watcher/-/hw-protocol-watcher-1.0.1.tgz", + "integrity": "sha512-eKfJctdYpAxhvF3W99C5UfsjbzeidP3jSm5KpksKXbqpzJkfgWhdJCurDWu1XURNEV8As7wQFel5ZgyJ3BMKrQ==", + "license": "MIT" + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1711,12 +1725,39 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", @@ -1726,6 +1767,21 @@ "hoist-non-react-statics": "^3.3.0" } }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.16.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.1.tgz", @@ -1778,6 +1834,12 @@ "redux": "^4.0.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", @@ -1971,8 +2033,7 @@ "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, "node_modules/@vitejs/plugin-react": { "version": "4.3.1", @@ -2066,6 +2127,16 @@ "node": ">=8" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2154,6 +2225,16 @@ } ] }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -2168,6 +2249,46 @@ "node": ">=4" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -2197,6 +2318,16 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2246,7 +2377,6 @@ "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -2259,12 +2389,34 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -2274,6 +2426,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2649,6 +2814,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2658,6 +2833,12 @@ "node": ">=0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2753,6 +2934,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fix-webm-duration": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fix-webm-duration/-/fix-webm-duration-1.0.6.tgz", + "integrity": "sha512-zVAqi4gE+8ywxJuAyV/rlJVX6CMtvyapEbQx6jyoeX9TMjdqAlt/FdG5d7rXSSkDVzTvS0H7CtwzHcH/vh4FPA==", + "license": "MIT" + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -2773,6 +2960,33 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/framer-motion": { + "version": "12.26.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.26.1.tgz", + "integrity": "sha512-Uzc8wGldU4FpmGotthjjcj0SZhigcODjqvKT7lzVZHsmYkzQMFfMIv0vHQoXCeoe/Ahxqp4by4A6QbzFA/lblw==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.24.11", + "motion-utils": "^12.24.10", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2893,6 +3107,46 @@ "node": ">=4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -2906,6 +3160,16 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -2957,6 +3221,46 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2978,6 +3282,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2996,6 +3310,18 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3332,6 +3658,16 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3361,89 +3697,972 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/memoize-one": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", - "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", - "dev": true, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, - "engines": { - "node": ">=8.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", "license": "MIT", "dependencies": { - "minipass": "^7.1.2" + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, - "engines": { - "node": ">= 18" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", + "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/motion-dom": { + "version": "12.24.11", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.11.tgz", + "integrity": "sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.24.10" + } + }, + "node_modules/motion-utils": { + "version": "12.24.10", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz", + "integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nanoid": { "version": "3.3.7", @@ -3550,6 +4769,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3654,6 +4898,16 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3742,6 +4996,33 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-redux": { "version": "7.2.9", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", @@ -3818,6 +5099,87 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3974,6 +5336,30 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -3998,6 +5384,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -4083,6 +5487,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -4095,6 +5519,12 @@ "typescript": ">=4.2.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4138,6 +5568,93 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "devOptional": true }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", @@ -4193,6 +5710,34 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "5.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz", @@ -4727,6 +6272,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/hyperdrive/packages/homepage/ui/package.json b/hyperdrive/packages/homepage/ui/package.json index 8ced13764..b39426dee 100644 --- a/hyperdrive/packages/homepage/ui/package.json +++ b/hyperdrive/packages/homepage/ui/package.json @@ -12,15 +12,23 @@ }, "dependencies": { "@hello-pangea/dnd": "^16.6.0", + "@hyperware-ai/hw-protocol-watcher": "^1.0.1", + "@types/mdast": "^4.0.4", "@tailwindcss/vite": "^4.1.11", "classnames": "^2.5.1", "dayjs": "^1.11.13", + "fix-webm-duration": "^1.0.6", + "framer-motion": "^12.24.12", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", "react-dom": "^18.2.0", "react-icons": "^5.1.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.23.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.11", + "unist-util-visit": "^5.0.0", "zustand": "^4.5.2" }, "devDependencies": { diff --git a/hyperdrive/packages/homepage/ui/public/browser-chat.html b/hyperdrive/packages/homepage/ui/public/browser-chat.html new file mode 100644 index 000000000..dd3eb15c0 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/public/browser-chat.html @@ -0,0 +1,354 @@ + + + + + + + Chat + + + +
+ + + + diff --git a/hyperdrive/packages/homepage/ui/public/chat-256.png b/hyperdrive/packages/homepage/ui/public/chat-256.png new file mode 100644 index 000000000..32f710339 Binary files /dev/null and b/hyperdrive/packages/homepage/ui/public/chat-256.png differ diff --git a/hyperdrive/packages/homepage/ui/src/App.css b/hyperdrive/packages/homepage/ui/src/App.css new file mode 100644 index 000000000..b55cae539 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/App.css @@ -0,0 +1,387 @@ +/* Root variables and reset - Stitch Design System */ +:root { + --primary-color: #ccff00; + --primary-hover: #b8e600; + --secondary-color: #00d4ff; + --danger-color: #ff4444; + --success-color: #51cf66; + --warning-color: #fbbf24; + --background: #050a18; + --surface: #0f172a; + --surface-hover: rgba(255, 255, 255, 0.05); + --text-primary: #ffffff; + --text-secondary: #94a3b8; + --border-color: rgba(255, 255, 255, 0.05); + --message-own: #ccff00; + --message-own-text: #000000; + --message-other: #0f172a; + --message-other-text: #ffffff; + --header-height: 60px; + --tab-bar-height: 60px; + --safe-area-bottom: env(safe-area-inset-bottom, 0); + --safe-area-top: env(safe-area-inset-top, 0); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Spline Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: var(--background); + color: var(--text-primary); + overscroll-behavior: none; + user-select: none; + -webkit-user-select: none; + touch-action: manipulation; + height: 100%; + overflow: hidden; +} + +/* Main app container */ +.app { + height: 100vh; + height: 100dvh; + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; + background: var(--background); +} + +/* Loading state */ +.app-loading { + height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + background: var(--background); +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Error state */ +.app-error { + height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + text-align: center; + background: var(--background); +} + +.app-error h2 { + margin-bottom: 10px; + color: var(--danger-color); +} + +.app-error button { + margin-top: 20px; + padding: 12px 24px; + background: var(--primary-color); + color: #000; + border: none; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.app-error button:hover { + transform: scale(1.02); + box-shadow: 0 4px 12px rgba(204, 255, 0, 0.3); +} + +/* Error banner */ +.error-banner { + position: absolute; + top: var(--safe-area-top); + left: 0; + right: 0; + background: var(--danger-color); + color: white; + padding: 10px 15px; + display: flex; + justify-content: space-between; + align-items: center; + z-index: 1000; + animation: slideDown 0.3s ease; +} + +@keyframes slideDown { + from { + transform: translateY(-100%); + } +} + +.dismiss-button { + background: none; + border: none; + color: white; + font-size: 24px; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; +} + +/* Modal overlay */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } +} + +.modal-content { + background: var(--surface); + border-radius: 16px; + width: 90%; + max-width: 400px; + max-height: 80vh; + overflow: auto; + animation: slideUp 0.3s ease; + padding: 0; + opacity: 1; + border: 1px solid var(--border-color); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +@keyframes slideUp { + from { + transform: translateY(100%); + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h2, +.modal-header h3 { + margin: 0; + font-weight: 600; +} + +.close-button { + background: none; + border: none; + font-size: 28px; + color: var(--text-secondary); + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + transition: background 0.2s ease, color 0.2s ease; +} + +.close-button:hover { + background: var(--surface-hover); + color: var(--primary-color); +} + +/* Form styles */ +.form-group { + margin-bottom: 20px; + padding: 0 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--border-color); + border-radius: 12px; + font-size: 15px; + background: var(--background); + color: var(--text-primary); + font-family: inherit; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.form-group input::placeholder, +.form-group textarea::placeholder { + color: var(--text-secondary); +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(204, 255, 0, 0.15); +} + +/* Button styles */ +button { + cursor: pointer; + transition: opacity 0.2s, transform 0.2s; +} + +button:active { + transform: scale(0.98); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.submit-button { + background: var(--primary-color); + color: #000; + border: none; + padding: 12px 24px; + border-radius: 12px; + font-size: 15px; + font-weight: 600; + box-shadow: 0 4px 12px rgba(204, 255, 0, 0.3); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.submit-button:hover:not(:disabled) { + transform: scale(1.02); + box-shadow: 0 6px 16px rgba(204, 255, 0, 0.4); +} + +.cancel-button { + background: var(--surface-hover); + color: var(--text-primary); + border: 1px solid var(--border-color); + padding: 12px 24px; + border-radius: 12px; + font-size: 15px; + font-weight: 500; +} + +.cancel-button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + border-color: var(--primary-color); +} + +.modal-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + padding: 20px; + border-top: 1px solid var(--border-color); +} + +/* Empty state */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + color: var(--text-secondary); + flex: 1; +} + +.empty-state .empty-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.6; +} + +.empty-state h3 { + margin-bottom: 8px; + color: var(--text-primary); + font-weight: 600; +} + +/* Responsive breakpoints */ +@media (min-width: 768px) { + .modal-content { + max-width: 500px; + } +} + +@media (min-width: 1024px) { + .app { + max-width: 1200px; + margin: 0 auto; + border-left: 1px solid var(--border-color); + border-right: 1px solid var(--border-color); + } +} + +/* Light mode support */ +@media (prefers-color-scheme: light) { + :root { + --background: #f5f6f8; + --surface: #ffffff; + --surface-hover: rgba(0, 0, 0, 0.05); + --text-primary: #0f172a; + --text-secondary: #64748b; + --border-color: rgba(0, 0, 0, 0.1); + --message-other: #e2e8f0; + --message-other-text: #0f172a; + --primary-color: #004fff; + --primary-hover: #0040cc; + --message-own: #004fff; + --message-own-text: #ffffff; + } + + .modal-overlay { + background: rgba(0, 0, 0, 0.5); + } + + .modal-content { + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + } +} diff --git a/hyperdrive/packages/homepage/ui/src/App.tsx b/hyperdrive/packages/homepage/ui/src/App.tsx new file mode 100644 index 000000000..6c4e9b5b5 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/App.tsx @@ -0,0 +1,403 @@ +import { useCallback, useEffect, useState } from 'react'; +import './App.css'; +import './styles/button-selectable.css'; +import './styles/bottom-bar.css'; +import './homepage/styles/animations.css'; +import { useChatStore } from './store/chat'; +import SplashScreen from './components/SplashScreen/SplashScreen'; +import ChatView from './components/Chat/ChatView'; +import SpiderChat from './components/Spider/SpiderChat'; +import { useGroupStore } from './store/groups'; +import GroupView from './components/Groups/GroupView'; +import GroupJoinModal from './components/Groups/GroupJoinModal'; +import { parseGroupJoinLink } from './utils/groupLinks'; +import type { GroupJoinTarget } from './utils/groupLinks'; +import { useAppStore } from './homepage/stores/appStore'; +import { useNavigationStore } from './homepage/stores/navigationStore'; +import type { HomepageApp } from './homepage/types/app.types'; +import { AppContainer } from './homepage/components/AppContainer'; +import { AppDrawer } from './homepage/components/AppDrawer'; +import { RecentApps } from './homepage/components/RecentApps'; +import { OmniButton } from './homepage/components/OmniButton'; +import UpdateNotification from './homepage/components/UpdateNotification'; +import InstallPrompt from './homepage/components/InstallPrompt'; +import { IframeMessageType, isIframeMessage } from './homepage/types/messages'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +dayjs.extend(relativeTime); + +type MainTab = 'chat' | 'apps' | 'wallet'; + +function App() { + const { + nodeId, + isConnected, + activeChat, + error, + initialize, + clearError, + chats, + isLoading, + } = useChatStore(); + const { activeGroup, loadGroups, fetchReplicationState } = useGroupStore(); + const [pendingJoin, setPendingJoin] = useState(null); + const [activeTab, setActiveTab] = useState('chat'); + const [showSpiderChat, setShowSpiderChat] = useState(false); + + const { apps, setApps } = useAppStore(); + const { + runningApps, + currentAppId, + isAppDrawerOpen, + isRecentAppsOpen, + initBrowserBackHandling, + openApp, + } = useNavigationStore(); + + const fetchApps = useCallback(async () => { + try { + const res = await fetch('/apps', { credentials: 'include' }); + const fetchedApps = (await res.json()) as HomepageApp[]; + setApps(fetchedApps); + return fetchedApps; + } catch (error) { + console.warn('Failed to fetch apps from backend:', error); + const fallbackApps: HomepageApp[] = [ + { + id: '1', + process: 'settings', + package_name: 'settings', + publisher: 'sys', + path: '/app:settings:sys.os/', + label: 'Settings', + order: 1, + favorite: true, + }, + { + id: '2', + process: 'files', + package_name: 'files', + publisher: 'sys', + path: '/app:files:sys.os/', + label: 'Files', + order: 2, + favorite: false, + }, + { + id: '3', + process: 'terminal', + package_name: 'terminal', + publisher: 'sys', + path: '/app:terminal:sys.os/', + label: 'Terminal', + order: 3, + favorite: false, + }, + { + id: '4', + process: 'browser', + package_name: 'browser', + publisher: 'sys', + path: '/app:browser:sys.os/', + label: 'Browser', + order: 4, + favorite: true, + }, + { + id: '5', + process: 'app-store', + package_name: 'app-store', + publisher: 'sys', + path: '/main:app-store:sys/', + label: 'App Store', + order: 5, + favorite: false, + widget: 'true', + }, + ]; + setApps(fallbackApps); + return fallbackApps; + } + }, [setApps]); + + // Initialize chat on mount + useEffect(() => { + initialize(); + }, [initialize]); + + // Prime group data when we have a connection + useEffect(() => { + if (isConnected) { + loadGroups(); + fetchReplicationState(null); + } + }, [isConnected, loadGroups, fetchReplicationState]); + + // Initialize browser back handling and app list + useEffect(() => { + initBrowserBackHandling(); + fetchApps(); + }, [initBrowserBackHandling, fetchApps]); + + // Open app from hash if we refreshed + useEffect(() => { + if (window?.location?.hash?.startsWith('#app-')) { + const hashWithoutPrefix = window.location.hash.replace('#app-', ''); + const appNameMatch = hashWithoutPrefix.match(/^([^/?]+)/); + const appNameToOpen = appNameMatch ? appNameMatch[1] : ''; + const remainder = hashWithoutPrefix.slice(appNameToOpen.length); + const appToOpen = apps?.find((app) => app?.id === appNameToOpen); + if (appToOpen) { + openApp(appToOpen, remainder || undefined); + } + } + }, [apps, openApp]); + + useEffect(() => { + const handleMessage = async (event: MessageEvent) => { + if (!isIframeMessage(event.data)) { + return; + } + + let allGood = true; + + const isValidOrigin = (() => { + const currentOrigin = window.location.origin; + const eventOrigin = event.origin; + + if (eventOrigin === currentOrigin) { + return true; + } + + const appStoreOrigin = currentOrigin.replace(/^(https?:\/\/)/, '$1app-store-sys.'); + if (eventOrigin === appStoreOrigin) { + return true; + } + + const currentUrl = new URL(currentOrigin); + const eventUrl = new URL(eventOrigin); + + if (currentUrl.protocol !== eventUrl.protocol || currentUrl.port !== eventUrl.port) { + return false; + } + + if (currentUrl.hostname.includes('localhost')) { + return eventUrl.hostname.endsWith('.localhost') || eventUrl.hostname === 'localhost'; + } + + const getCurrentBaseDomain = (hostname: string) => { + const parts = hostname.split('.'); + return parts.length >= 2 ? parts.slice(-2).join('.') : hostname; + }; + + const currentBaseDomain = getCurrentBaseDomain(currentUrl.hostname); + const eventBaseDomain = getCurrentBaseDomain(eventUrl.hostname); + + return currentBaseDomain === eventBaseDomain; + })(); + + if (!isValidOrigin) { + allGood = false; + } + + if (!isIframeMessage(event.data)) { + allGood = false; + } + + if (!allGood) { + return; + } + + if (event.data.type === IframeMessageType.OPEN_APP) { + const { id } = event.data; + const fetchedApps = await fetchApps(); + const appMatches = fetchedApps?.filter((app) => app.id.endsWith(':' + id)); + if (appMatches?.length > 1) { + console.error('Multiple apps found with the same id:', { id, fetchedApps }); + } else if (appMatches.length === 0) { + console.error('App not found:', { id, fetchedApps }); + } + const app = appMatches?.[0]; + if (app) { + openApp(app); + } + } else if (event.data.type === IframeMessageType.APP_LINK_CLICKED) { + const { url } = event.data; + const app = apps.find((entry) => entry.id.endsWith('app-store:sys')); + if (app) { + openApp(app, url); + } + } else if (event.data.type === IframeMessageType.HW_LINK_CLICKED) { + const { url } = event.data; + const urlParts = url + .split('/') + .filter((part) => part !== '' && part !== null && part !== undefined); + const appName = urlParts[0]; + const path = urlParts.slice(1).join('/'); + const app = apps.find((entry) => entry.id.endsWith(appName)); + if (app) { + openApp(app, path || undefined); + } + } + }; + window.addEventListener('message', handleMessage); + + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [apps, fetchApps, openApp]); + + useEffect(() => { + const parsed = parseGroupJoinLink(window.location.pathname); + if (parsed) { + setPendingJoin(parsed); + const parts = window.location.pathname.split('/').filter(Boolean); + const joinIndex = parts.indexOf('join-group'); + if (joinIndex !== -1) { + const baseParts = parts.slice(0, joinIndex); + const basePath = baseParts.length ? `/${baseParts.join('/')}/` : '/'; + window.history.replaceState( + {}, + '', + `${basePath}${window.location.search}${window.location.hash}`, + ); + } + } + }, []); + + useEffect(() => { + const handleLinkClick = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + const anchor = target?.closest('a'); + const href = anchor?.getAttribute('href'); + if (!href) return; + const parsed = parseGroupJoinLink(href); + if (!parsed) return; + event.preventDefault(); + event.stopPropagation(); + setPendingJoin(parsed); + }; + document.addEventListener('click', handleLinkClick, true); + return () => document.removeEventListener('click', handleLinkClick, true); + }, []); + + const isChatDetail = activeTab === 'chat' && Boolean(activeChat || activeGroup); + const isBottomBarHidden = isChatDetail || Boolean(currentAppId) || showSpiderChat; + const shouldShowOmniButton = runningApps.length > 0; + const showAppsView = activeTab === 'apps' && !currentAppId; + + if (chats.length === 0 && !nodeId && !error && isLoading) { + return ( +
+
+

Connecting to Hyperware...

+
+ ); + } + + if (!isConnected && error && chats.length === 0) { + return ( +
+

Connection Error

+

{error}

+ +
+ ); + } + + return ( +
+ {error && ( +
+ {error} + +
+ )} + +
+ {showAppsView ? ( + + ) : activeGroup ? ( + + ) : ( + <> + + {activeChat && } + {showSpiderChat && ( + setShowSpiderChat(false)} /> + )} + + )} +
+ + {runningApps.map((app) => ( + + ))} + + + + {shouldShowOmniButton && } + + + + + + + + {pendingJoin && ( + setPendingJoin(null)} + /> + )} +
+ ); +} + +export default App; diff --git a/hyperdrive/packages/homepage/ui/src/components/Calls/CallHistory.css b/hyperdrive/packages/homepage/ui/src/components/Calls/CallHistory.css new file mode 100644 index 000000000..5b66e570e --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Calls/CallHistory.css @@ -0,0 +1,6 @@ +.call-history-container { + display: flex; + flex-direction: column; + height: 100%; + padding: 20px; +} \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Calls/CallHistory.tsx b/hyperdrive/packages/homepage/ui/src/components/Calls/CallHistory.tsx new file mode 100644 index 000000000..d1dca2684 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Calls/CallHistory.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import './CallHistory.css'; + +const CallHistory: React.FC = () => { + return ( +
+
+ 📞 +

Voice Calls Coming Soon

+

Voice call functionality will be available in a future update.

+
+
+ ); +}; + +export default CallHistory; \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/ChatHeader.css b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatHeader.css new file mode 100644 index 000000000..527fafd05 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatHeader.css @@ -0,0 +1,161 @@ +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: rgba(5, 10, 24, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + height: var(--header-height, 60px); + position: sticky; + top: 0; + z-index: 20; +} + +@media (prefers-color-scheme: light) { + .chat-header { + background: rgba(245, 246, 248, 0.7); + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + } +} + +.back-button { + background: none; + border: none; + font-size: 24px; + color: var(--iris, #004fff); + padding: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 8px; + transition: background 0.2s ease; +} + +.back-button:hover { + background: var(--surface-hover, rgba(255, 255, 255, 0.05)); +} + +.back-button .material-symbols-outlined { + font-size: 24px; +} + +.chat-header-info { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + padding: 4px 8px; + border-radius: 8px; + transition: background 0.2s ease; +} + +.chat-header-info:hover { + background: var(--surface-hover, rgba(255, 255, 255, 0.05)); +} + +.chat-header-info .chat-avatar-wrap { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* Lime green glow for official/AI chats */ +.chat-header-info.official-chat .chat-avatar-wrap { + filter: drop-shadow(0 0 8px rgba(204, 255, 0, 0.45)); +} + +.chat-header-info .official-glow { + position: absolute; + inset: -4px; + border-radius: 50%; + background: linear-gradient(135deg, var(--primary-color), #00d4ff); + opacity: 0.6; + filter: blur(6px); + animation: header-glow-pulse 2s ease-in-out infinite; + pointer-events: none; +} + +@keyframes header-glow-pulse { + 0%, 100% { + opacity: 0.6; + } + 50% { + opacity: 0.8; + } +} + +.chat-header-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + background: var(--surface); +} + +.chat-header-name { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + display: inline-flex; + align-items: center; + gap: 8px; +} + +/* AI badge - Stitch style */ +.chat-header-name .official-badge { + font-size: 10px; + font-weight: 500; + padding: 2px 6px; + border-radius: 6px; + background: rgba(204, 255, 0, 0.2); + color: var(--primary-color); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.chat-header-subtitle { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; +} + +.chat-header-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.voice-call-button, +.settings-button { + background: none; + border: none; + font-size: 22px; + color: var(--text-secondary); + padding: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 8px; + transition: background 0.2s ease, color 0.2s ease; +} + +.voice-call-button:hover, +.settings-button:hover { + background: var(--surface-hover, rgba(255, 255, 255, 0.05)); + color: var(--primary-color); +} + +.voice-call-button .material-symbols-outlined, +.settings-button .material-symbols-outlined { + font-size: 22px; +} diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/ChatHeader.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatHeader.tsx new file mode 100644 index 000000000..360821a1d --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatHeader.tsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import { Chat } from '#caller-utils'; +import Avatar from '../Common/Avatar'; +import ChatSettings from './ChatSettings'; +import './ChatHeader.css'; + +interface ChatHeaderProps { + chat: Chat.Chat; + onBack: () => void; +} + +const ChatHeader: React.FC = ({ chat, onBack }) => { + const [showSettings, setShowSettings] = useState(false); + const isOfficial = chat.counterparty === 'dao.hypr'; + + return ( + <> +
+ + +
+
+ + {isOfficial &&
+ + {chat.counterparty} + {isOfficial && official} + +
+ + +
+ + {showSettings && ( + setShowSettings(false)} /> + )} + + ); +}; + +export default ChatHeader; diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/ChatSettings.css b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatSettings.css new file mode 100644 index 000000000..f9ed4bc49 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatSettings.css @@ -0,0 +1,19 @@ +.chat-settings-modal { + background: var(--background); + border-radius: 12px; + width: 90%; + max-width: 400px; + padding: 0; +} + +.delete-chat-button { + background: var(--danger-color); + color: white; + border: none; + padding: 12px 24px; + border-radius: 8px; + font-size: 16px; + width: 100%; + margin-top: 20px; + cursor: pointer; +} \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/ChatSettings.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatSettings.tsx new file mode 100644 index 000000000..2a513bf4f --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatSettings.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Chat } from '#caller-utils'; +import { useChatStore } from '../../store/chat'; +import './ChatSettings.css'; + +interface ChatSettingsProps { + chat: Chat.Chat; + onClose: () => void; +} + +const ChatSettings: React.FC = ({ chat, onClose }) => { + const { deleteChat, updateChatSettings } = useChatStore(); + + const handleBlockToggle = () => { + // TODO: Implement block functionality + console.log('Block toggle'); + }; + + const handleNotifyToggle = async () => { + await updateChatSettings(chat.id, { notify: !chat.notify }); + }; + + const handleDeleteChat = async () => { + if (confirm('Are you sure you want to delete this chat?')) { + await deleteChat(chat.id); + onClose(); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

Chat Settings

+ +
+ +
+
+ +
+ +
+ +
+ + +
+
+
+ ); +}; + +export default ChatSettings; \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/ChatView.css b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatView.css new file mode 100644 index 000000000..1382abe68 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatView.css @@ -0,0 +1,218 @@ +@keyframes slide-in-from-right { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +@keyframes slide-out-to-right { + from { + transform: translateX(0); + } + to { + transform: translateX(100%); + } +} + +.chat-view { + display: flex; + flex-direction: column; + height: 100vh; + height: 100dvh; + background: var(--background); + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + z-index: 10; + animation: slide-in-from-right 0.3s ease-out; +} + +.chat-view.closing { + animation: slide-out-to-right 0.3s ease-out forwards; +} + +.messages-container { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 10px 16px; + padding-bottom: 20px; + -webkit-overflow-scrolling: touch; + min-height: 0; + position: relative; + /* Prevent iOS bounce when pulling down */ + overscroll-behavior-y: contain; +} + +/* Hide scrollbar for cleaner look */ +.messages-container::-webkit-scrollbar { + display: none; +} + +.messages-container { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* Sync indicator for pull-to-refresh */ +.sync-indicator { + position: absolute; + top: 0; + left: 0; + right: 0; + background: var(--surface); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; + transition: height 0.2s ease-out, opacity 0.2s ease-out; + overflow: hidden; + z-index: 10; +} + +.sync-indicator-content { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: var(--text-secondary); +} + +.sync-spinner { + animation: spin 1s linear infinite; + font-size: 18px; + color: var(--primary-color); +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Swipe navigation styles */ +.chat-view.swiping { + user-select: none; + -webkit-user-select: none; +} + +/* Offline tooltip styles */ +.offline-tooltip { + position: absolute; + top: 60px; /* Below the header */ + left: 10px; + right: 10px; + z-index: 100; + animation: slideDown 0.3s ease-out; +} + +.offline-tooltip-content { + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + color: white; + padding: 10px 15px; + border-radius: 12px; + font-size: 13px; + text-align: center; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +@keyframes slideDown { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Scroll to bottom button - Stitch lime green */ +.scroll-to-bottom-button { + position: absolute; + bottom: 100px; /* Well above the message input to avoid overlap */ + right: 16px; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--primary-color); + color: #000; + border: none; + font-size: 20px; + line-height: 1; + cursor: pointer; + box-shadow: 0 4px 12px rgba(204, 255, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.2s ease; + z-index: 50; + animation: fadeIn 0.2s ease; +} + +/* Mobile-specific positioning */ +@media (max-width: 768px) { + .scroll-to-bottom-button { + bottom: 120px; /* Much higher on mobile to avoid input bar overlap */ + right: 12px; + width: 36px; + height: 36px; + font-size: 18px; + } +} + +.scroll-to-bottom-button:hover { + transform: scale(1.1); + box-shadow: 0 6px 16px rgba(204, 255, 0, 0.4); +} + +.scroll-to-bottom-button:active { + transform: scale(0.95); +} + +.scroll-to-bottom-button .material-symbols-outlined { + font-size: 20px; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Date separator */ +.date-separator { + display: flex; + align-items: center; + justify-content: center; + padding: 16px 0; +} + +.date-separator-text { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + background: var(--surface); + padding: 4px 12px; + border-radius: 12px; +} + +/* Light mode adjustments */ +@media (prefers-color-scheme: light) { + .offline-tooltip-content { + background: rgba(15, 23, 42, 0.9); + } +} diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/ChatView.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatView.tsx new file mode 100644 index 000000000..ded79ac45 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatView.tsx @@ -0,0 +1,305 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { useChatStore } from '../../store/chat'; +import MessageList from './MessageList'; +import MessageInput from './MessageInput'; +import ChatHeader from './ChatHeader'; +import './ChatView.css'; +import { Chat } from '#caller-utils'; + +const ChatView: React.FC = () => { + const { + activeChat, + markChatAsRead, + setActiveChat, + forceSyncChat, + jumpToMessageId, + setJumpToMessageId + } = useChatStore(); + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + const [swipeX, setSwipeX] = useState(0); + const [isSwiping, setIsSwiping] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const [showScrollButton, setShowScrollButton] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [pullDistance, setPullDistance] = useState(0); + const startXRef = useRef(0); + const startYRef = useRef(0); + const lastScrollTopRef = useRef(0); + const isAtBottomRef = useRef(false); + const prevMessageCountRef = useRef(0); + const prevChatIdRef = useRef(null); + const touchStartYRef = useRef(0); + const chatViewRef = useRef(null); + + useEffect(() => { + if (activeChat) { + markChatAsRead(activeChat.id); + + } + }, [activeChat, markChatAsRead]); + + useEffect(() => { + if (!activeChat) return; + const messageCount = activeChat.messages.length; + const chatChanged = activeChat.id !== prevChatIdRef.current; + + if (jumpToMessageId) { + prevMessageCountRef.current = messageCount; + prevChatIdRef.current = activeChat.id; + return; + } + + if (chatChanged) { + messagesEndRef.current?.scrollIntoView({ behavior: 'instant' }); + } else if ( + messageCount > prevMessageCountRef.current && + isAtBottomRef.current + ) { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + + prevMessageCountRef.current = messageCount; + prevChatIdRef.current = activeChat.id; + }, [activeChat?.id, activeChat?.messages.length, jumpToMessageId]); + + useEffect(() => { + if (!activeChat || !jumpToMessageId) return; + const messageId = jumpToMessageId; + const timer = window.setTimeout(() => { + const element = document.getElementById(`message-${messageId}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + element.classList.add('highlight'); + window.setTimeout(() => element.classList.remove('highlight'), 2000); + } + setJumpToMessageId(null); + }, 50); + + return () => window.clearTimeout(timer); + }, [activeChat?.id, activeChat?.messages.length, jumpToMessageId, setJumpToMessageId]); + + // Check if scrolled to bottom for scroll button and sync trigger + useEffect(() => { + const container = messagesContainerRef.current; + if (!container) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = container; + + // Check if at bottom + const isAtBottom = scrollHeight - scrollTop - clientHeight < 10; + isAtBottomRef.current = isAtBottom; + setShowScrollButton(!isAtBottom); + + // Store last scroll position + lastScrollTopRef.current = scrollTop; + }; + + container.addEventListener('scroll', handleScroll); + handleScroll(); // Check initial state + + return () => container.removeEventListener('scroll', handleScroll); + }, [activeChat]); + + // Scroll to bottom function + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + // Handle pull-to-refresh for syncing + const handleMessagesTouchStart = (e: React.TouchEvent) => { + const container = messagesContainerRef.current; + if (!container) return; + + // Check if we're at the bottom + const { scrollTop, scrollHeight, clientHeight } = container; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 10; + + if (isAtBottom) { + touchStartYRef.current = e.touches[0].clientY; + } + }; + + const handleMessagesTouchMove = (e: React.TouchEvent) => { + if (touchStartYRef.current === 0 || isSyncing) return; + + const container = messagesContainerRef.current; + if (!container) return; + + // Check if still at bottom + const { scrollTop, scrollHeight, clientHeight } = container; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 10; + + if (!isAtBottom) { + // User has scrolled up, cancel pull-to-refresh + touchStartYRef.current = 0; + setPullDistance(0); + return; + } + + const currentY = e.touches[0].clientY; + const deltaY = currentY - touchStartYRef.current; // Positive when pulling down + + // If pulling down past the bottom + if (deltaY > 10) { + // Prevent default to stop bounce on iOS + e.preventDefault(); + + const pull = Math.min(deltaY, 100); + setPullDistance(pull); + + // Add haptic feedback at threshold + if (pull >= 60 && pull < 65 && 'vibrate' in navigator) { + navigator.vibrate(10); + } + } + }; + + const handleMessagesTouchEnd = async () => { + if (pullDistance >= 60 && !isSyncing && activeChat) { + // Trigger sync + setIsSyncing(true); + setPullDistance(0); + + try { + console.log('[PULL-REFRESH] Syncing chat:', activeChat.id); + await forceSyncChat(activeChat.id); + } finally { + setIsSyncing(false); + } + } else { + setPullDistance(0); + } + + // Reset touch start + touchStartYRef.current = 0; + }; + + // Swipe handlers for navigation + const handleTouchStart = (e: React.TouchEvent) => { + // Only handle swipe if starting from left edge + if (e.touches[0].clientX < 30) { + startXRef.current = e.touches[0].clientX; + startYRef.current = e.touches[0].clientY; + setIsSwiping(false); + } + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (startXRef.current === 0) return; + + const currentX = e.touches[0].clientX; + const currentY = e.touches[0].clientY; + const deltaX = currentX - startXRef.current; + const deltaY = Math.abs(currentY - startYRef.current); + + // Only trigger swipe if horizontal movement is greater than vertical + // and swiping right from left edge + if (deltaX > 10 && deltaY < 50 && startXRef.current < 30) { + setIsSwiping(true); + // Limit swipe distance + const limitedDeltaX = Math.min(deltaX, window.innerWidth * 0.8); + setSwipeX(limitedDeltaX); + + // Add haptic feedback when reaching threshold + if (limitedDeltaX >= window.innerWidth * 0.3 && 'vibrate' in navigator) { + navigator.vibrate(10); + } + } + }; + + const handleTouchEnd = () => { + if (swipeX >= window.innerWidth * 0.3) { + // Go back to chat list + setActiveChat(null); + } + setSwipeX(0); + setIsSwiping(false); + startXRef.current = 0; + }; + + // Handle back button with animation + const handleBack = useCallback(() => { + setIsClosing(true); + setTimeout(() => { + setActiveChat(null); + }, 300); + }, [setActiveChat]); + + if (!activeChat) { + return null; + } + + return ( +
+ + +
+ {/* Pull-to-refresh indicator */} + {(pullDistance > 0 || isSyncing) && ( +
0 ? `${pullDistance}px` : '60px', + opacity: pullDistance > 0 ? Math.min(pullDistance / 60, 1) : 1 + }} + > +
+ {isSyncing ? ( + <> + + Syncing... + + ) : pullDistance >= 60 ? ( + <> + + Release to sync + + ) : ( + <> + + Pull to sync + + )} +
+
+ )} + + +
+
+ + {showScrollButton && ( + + )} + + +
+ ); +}; + +export default ChatView; diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/DeleteMessageModal.css b/hyperdrive/packages/homepage/ui/src/components/Chat/DeleteMessageModal.css new file mode 100644 index 000000000..8d17ed02c --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/DeleteMessageModal.css @@ -0,0 +1,76 @@ +.delete-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.delete-modal { + background: var(--background-secondary, #1a1a1a); + border-radius: 12px; + padding: 24px; + max-width: 400px; + width: 90%; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.delete-modal h3 { + margin: 0 0 12px 0; + color: var(--text-primary); + font-size: 20px; +} + +.delete-modal p { + color: var(--text-secondary); + margin-bottom: 24px; +} + +.delete-modal-buttons { + display: flex; + flex-direction: column; + gap: 12px; +} + +.delete-button { + padding: 12px 20px; + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + transition: all 0.2s; + font-weight: 500; +} + +.delete-button.delete-locally { + background: var(--button-secondary, #333); + color: var(--text-primary); +} + +.delete-button.delete-locally:hover { + background: var(--button-secondary-hover, #444); +} + +.delete-button.delete-both { + background: #dc3545; + color: white; +} + +.delete-button.delete-both:hover { + background: #c82333; +} + +.delete-button.cancel { + background: transparent; + color: var(--text-secondary, #999); + border: 1px solid var(--border-color, #333); +} + +.delete-button.cancel:hover { + background: var(--background-tertiary, rgba(255,255,255,0.05)); +} \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/DeleteMessageModal.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/DeleteMessageModal.tsx new file mode 100644 index 000000000..fbb897425 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/DeleteMessageModal.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import './DeleteMessageModal.css'; + +interface DeleteMessageModalProps { + isOpen: boolean; + onClose: () => void; + onDeleteLocally: () => void; + onDeleteForBoth: () => void; + isOwnMessage: boolean; +} + +const DeleteMessageModal: React.FC = ({ + isOpen, + onClose, + onDeleteLocally, + onDeleteForBoth, + isOwnMessage +}) => { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +

Delete Message

+

Choose how you want to delete this message:

+ +
+ + + {isOwnMessage && ( + + )} + + +
+
+
+ ); +}; + +export default DeleteMessageModal; \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/FileUpload.css b/hyperdrive/packages/homepage/ui/src/components/Chat/FileUpload.css new file mode 100644 index 000000000..bbd623310 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/FileUpload.css @@ -0,0 +1,98 @@ +.file-upload-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: flex-end; + z-index: 1000; +} + +.file-upload-menu { + background: var(--background); + width: 100%; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.upload-option { + padding: 15px; + background: var(--surface); + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + transition: background 0.2s; +} + +.upload-option:active { + background: var(--border-color); +} + +.upload-option label { + display: block; + width: 100%; + cursor: pointer; +} + +/* Upload progress styles */ +.upload-progress-container { + background: var(--surface); + border-radius: 8px; + padding: 15px; + margin-bottom: 10px; +} + +.upload-progress-item { + margin-bottom: 15px; +} + +.upload-progress-item:last-child { + margin-bottom: 0; +} + +.upload-filename { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-progress-bar { + height: 6px; + background: var(--border-color); + border-radius: 3px; + overflow: hidden; + margin-bottom: 5px; +} + +.upload-progress-fill { + height: 100%; + background: var(--primary); + border-radius: 3px; + transition: width 0.3s ease; +} + +.upload-progress-text { + font-size: 12px; + color: var(--text-secondary); + text-align: right; +} + +.upload-progress-item.has-error .upload-filename { + color: #ff4d4d; +} + +.upload-error { + font-size: 12px; + color: #ff4d4d; + margin-top: 4px; +} \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/FileUpload.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/FileUpload.tsx new file mode 100644 index 000000000..c68e5cbe4 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/FileUpload.tsx @@ -0,0 +1,183 @@ +import React, { useState, useRef, useCallback } from 'react'; +import './FileUpload.css'; +import { useChatStore } from '../../store/chat'; +import * as Caller from '#caller-utils'; + +interface FileUploadProps { + onClose: () => void; +} + +interface UploadStatus { + progress: number; + error?: string; +} + +const { upload_file } = Caller.Chat; + +const FileUpload: React.FC = ({ onClose }) => { + const { activeChat, settings } = useChatStore(); + const [isUploading, setIsUploading] = useState(false); + const [uploadStatus, setUploadStatus] = useState<{ [filename: string]: UploadStatus }>({}); + const pendingUploadsRef = useRef(0); + + const readFileAsBase64 = useCallback((file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onprogress = (event) => { + if (event.lengthComputable) { + const percentComplete = (event.loaded / event.total) * 50; // 50% for reading + setUploadStatus(prev => ({ + ...prev, + [file.name]: { ...prev[file.name], progress: percentComplete } + })); + } + }; + + reader.onload = (event) => { + if (event.target?.result) { + const base64 = (event.target.result as string).split(',')[1]; + resolve(base64); + } else { + reject(new Error('Failed to read file')); + } + }; + + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsDataURL(file); + }); + }, []); + + const uploadSingleFile = useCallback(async (file: File, chatId: string, maxSizeBytes: number) => { + const filename = file.name; + + // Check file size + if (file.size > maxSizeBytes) { + const maxMb = Math.round(maxSizeBytes / (1024 * 1024)); + setUploadStatus(prev => ({ + ...prev, + [filename]: { progress: 0, error: `Exceeds ${maxMb}MB limit` } + })); + return; + } + + // Set initial progress + setUploadStatus(prev => ({ ...prev, [filename]: { progress: 0 } })); + + try { + const base64 = await readFileAsBase64(file); + + // Update progress to show uploading + setUploadStatus(prev => ({ ...prev, [filename]: { progress: 50 } })); + + // Upload file + await upload_file({ + chat_id: chatId, + filename, + mime_type: file.type || 'application/octet-stream', + data: base64, + reply_to: null + }); + + // Mark as complete + setUploadStatus(prev => ({ ...prev, [filename]: { progress: 100 } })); + } catch (error) { + console.error('Error uploading file:', error); + setUploadStatus(prev => ({ + ...prev, + [filename]: { progress: 0, error: 'Upload failed' } + })); + } + }, [readFileAsBase64]); + + const handleFileSelect = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0 || !activeChat) return; + + const maxSizeBytes = (settings.max_file_size_mb || 10) * 1024 * 1024; + const fileArray = Array.from(files); + + setIsUploading(true); + pendingUploadsRef.current = fileArray.length; + + // Upload all files in parallel + await Promise.all( + fileArray.map(file => uploadSingleFile(file, activeChat.id, maxSizeBytes)) + ); + + // Close dialog after a short delay to show completion + setTimeout(() => { + setIsUploading(false); + // Check if all uploads succeeded (no errors) + const hasErrors = Object.values(uploadStatus).some(s => s.error); + if (!hasErrors) { + onClose(); + } + }, 1000); + }; + + return ( +
+
e.stopPropagation()}> + {/* Show upload progress if uploading */} + {Object.keys(uploadStatus).length > 0 && ( +
+ {Object.entries(uploadStatus).map(([filename, status]) => ( +
+
{filename}
+ {status.error ? ( +
{status.error}
+ ) : ( + <> +
+
+
+
{Math.round(status.progress)}%
+ + )} +
+ ))} +
+ )} + + {/* Show upload options when not uploading */} + {!isUploading && ( + <> + + + + + )} +
+
+ ); +}; + +export default FileUpload; diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/Message.css b/hyperdrive/packages/homepage/ui/src/components/Chat/Message.css new file mode 100644 index 000000000..5a6aaa579 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/Message.css @@ -0,0 +1,355 @@ +.message { + max-width: 75%; + display: flex; + flex-direction: column; + gap: 0; + position: relative; + animation: message-enter 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + opacity: 0; + transform: translateY(8px) scale(0.96); +} + +/* Telegram-style message entrance */ +@keyframes message-enter { + 0% { + opacity: 0; + transform: translateY(8px) scale(0.96); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Own message "rise from input" animation */ +@keyframes message-send { + 0% { + opacity: 0; + transform: translateY(40px) scale(0.92); + } + 60% { + opacity: 1; + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.message.own:not(.no-animate) { + animation: message-send 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +/* Disable animation for messages already in view on load */ +.message.no-animate { + animation: none; + opacity: 1; + transform: none; +} + +/* Time-based spacing */ +.message.spacing-tight { + margin-top: 2px; +} + +.message.spacing-wide { + margin-top: 6px; +} + +.message.own { + align-self: flex-end; +} + +.message.other { + align-self: flex-start; +} + +/* Official/AI message glow - lime green for Stitch */ +.message.official-message .message-content { + box-shadow: 0 0 0 1px rgba(204, 255, 0, 0.35), + 0 0 12px rgba(204, 255, 0, 0.15); +} + +/* Reply-to styling */ +.reply-to { + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 6px 10px; + margin-bottom: 6px; + border-left: 3px solid var(--primary-color); + cursor: pointer; + transition: background 0.2s ease; +} + +.reply-to:hover { + background: rgba(255, 255, 255, 0.1); +} + +.message.own .reply-to { + background: rgba(0, 0, 0, 0.15); + border-left-color: rgba(0, 0, 0, 0.4); +} + +.message.own .reply-to:hover { + background: rgba(0, 0, 0, 0.2); +} + +.reply-to-label { + font-size: 11px; + color: var(--primary-color); + margin-bottom: 2px; + font-weight: 500; +} + +.message.own .reply-to-label { + color: rgba(0, 0, 0, 0.6); +} + +.reply-to-content { + font-size: 13px; + color: var(--text-primary); + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +.message.own .reply-to-content { + color: rgba(0, 0, 0, 0.8); +} + +/* Message content bubble */ +.message-content { + padding: 10px 56px 20px 14px; /* Extra right padding for time, bottom for footer overlay */ + border-radius: 18px; + font-size: 15px; + line-height: 1.45; + word-wrap: break-word; + position: relative; + min-width: 100px; /* Ensure minimum width for short messages to prevent emoji-time overlap */ +} + +/* Own messages - Stitch lime green */ +.message.own .message-content { + background: var(--message-own, #ccff00); + color: var(--message-own-text, #000); + border-bottom-right-radius: 4px; +} + +/* Other messages - Surface dark */ +.message.other .message-content { + padding: 10px 48px 20px 14px; /* Less right padding for other's messages (no status icon) */ + background: var(--message-other, #0f172a); + color: var(--message-other-text, #fff); + border-bottom-left-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +/* Message footer (time and status) */ +.message-footer { + position: absolute; + bottom: 4px; + right: 12px; + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: rgba(0, 0, 0, 0.5); + z-index: 1; + white-space: nowrap; /* Keep time on single line */ +} + +.message.other .message-footer { + color: var(--text-secondary); +} + +.message-time { + font-weight: 400; +} + +.message-status { + font-size: 14px; + color: rgba(0, 0, 0, 0.5); +} + +.message.other .message-status { + color: var(--primary-color); +} + +/* Reactions */ +.message-reactions { + position: absolute; + bottom: -10px; + left: 8px; + display: flex; + flex-wrap: wrap; + gap: 4px; + z-index: 2; +} + +.reaction { + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + background: var(--surface); + font-size: 12px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + animation: reaction-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +@keyframes reaction-pop { + 0% { + opacity: 0; + transform: scale(0.5); + } + 70% { + transform: scale(1.15); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +.reaction:hover { + background: var(--primary-color); + color: #000; + border-color: var(--primary-color); + transform: scale(1.1); +} + +.reaction:active { + transform: scale(0.95); +} + +.reaction.reacted { + background: var(--primary-color); + color: #000; + border-color: var(--primary-color); +} + +/* Highlight animation for scrolled-to messages */ +.message.highlight { + animation: highlight-pulse 2s ease-out; +} + +@keyframes highlight-pulse { + 0% { + background-color: rgba(204, 255, 0, 0.3); + border-radius: 8px; + } + 100% { + background-color: transparent; + } +} + +/* Swipe to reply styles */ +.message.swiping { + user-select: none; + -webkit-user-select: none; +} + +.message.reply-triggered { + animation: reply-feedback 0.3s ease-out; +} + +@keyframes reply-feedback { + 0% { + transform: translateX(0); + } + 50% { + transform: translateX(10px); + } + 100% { + transform: translateX(0); + } +} + +/* Voice note / audio player styles */ +.dm-audio-wrapper { + background: var(--surface); + border-radius: 12px; + padding: 8px 12px; + margin: 2px 0; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.dm-audio-player { + width: 100%; + min-width: 220px; + height: 32px; + border: none; + outline: none; + background: transparent; + display: block; +} + +/* Image messages */ +.message-image { + max-width: 100%; + max-height: 300px; + border-radius: 12px; + object-fit: contain; +} + +/* Code blocks in messages */ +.message-content pre { + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + padding: 10px; + overflow-x: auto; + margin: 8px 0; +} + +.message.own .message-content pre { + background: rgba(0, 0, 0, 0.15); +} + +.message-content code { + font-family: 'SF Mono', Monaco, 'Courier New', monospace; + font-size: 13px; +} + +/* Links in messages */ +.message-content a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; +} + +.message.own .message-content a { + color: rgba(0, 0, 0, 0.8); +} + +.message.other .message-content a { + color: var(--primary-color); +} + +/* Light mode */ +@media (prefers-color-scheme: light) { + .message.other .message-content { + background: var(--message-other, #e2e8f0); + color: var(--message-other-text, #0f172a); + border-color: rgba(0, 0, 0, 0.05); + } + + .reply-to { + background: rgba(0, 0, 0, 0.05); + } + + .reply-to:hover { + background: rgba(0, 0, 0, 0.08); + } + + .reaction { + background: #fff; + border-color: rgba(0, 0, 0, 0.1); + } + + .dm-audio-wrapper { + border-color: rgba(0, 0, 0, 0.05); + } +} diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/Message.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/Message.tsx new file mode 100644 index 000000000..0444e8991 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/Message.tsx @@ -0,0 +1,727 @@ +import React, { useState, useMemo, useRef, useEffect } from 'react'; +import { Chat } from '#caller-utils'; +import MessageMenu from './MessageMenu'; +import './Message.css'; +import * as Caller from '#caller-utils'; +import { useChatStore } from '../../store/chat'; +import ReactMarkdown from 'react-markdown'; +import remarkBreaks from 'remark-breaks'; +import remarkHwProtocol from '../../utils/remarkHwProtocol'; +import { normalizeMessageContent } from '../../utils/normalizeMessageContent'; +import { SpacingClass } from '../../utils/messageSpacing'; +import { getChatBasePath } from '../../utils/chatBase'; + +interface MessageProps { + message: Chat.ChatMessage; + isOwn: boolean; + spacingClass?: SpacingClass; +} + +const { add_reaction, remove_reaction } = Caller.Chat; + +const Message: React.FC = ({ message, isOwn, spacingClass = 'wide' }) => { + const [showMenu, setShowMenu] = useState(false); + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); + const [swipeX, setSwipeX] = useState(0); + const [isSwiping, setIsSwiping] = useState(false); + const [audioUrl, setAudioUrl] = useState(null); + const audioUrlRef = useRef(null); + const { activeChat, settings, setReplyingTo } = useChatStore(); + const messageRef = useRef(null); + const isOfficial = message.sender === 'dao.hypr'; + const startXRef = useRef(0); + const startYRef = useRef(0); + const longPressTimerRef = useRef | null>(null); + const touchStartTimeRef = useRef(0); + + // Clean up timer on unmount + useEffect(() => { + return () => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + } + }; + }, []); + + useEffect(() => { + return () => { + if (audioUrlRef.current && audioUrlRef.current.startsWith('blob:')) { + URL.revokeObjectURL(audioUrlRef.current); + } + }; + }, []); + + const formatTime = (timestamp: number) => { + const date = new Date(timestamp * 1000); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + }; + + const getStatusIcon = () => { + switch (message.status) { + case Chat.MessageStatus.Sending: + return '...'; + case Chat.MessageStatus.Sent: + return '✓'; + case Chat.MessageStatus.Delivered: + return '✓✓'; + case Chat.MessageStatus.Failed: + return '❌'; + default: + return ''; + } + }; + + const buildDownloadUrl = (rawUrl: string) => { + if (!rawUrl.startsWith('/')) { + return rawUrl; + } + + const basePath = getChatBasePath(); + + if (rawUrl.startsWith(`${basePath}/`)) { + return rawUrl; + } + + return `${basePath}${rawUrl}`; + }; + + const extractFileRef = (rawUrl: string) => { + const filesIndex = rawUrl.indexOf('/files/'); + if (filesIndex === -1) { + return null; + } + + const rest = rawUrl + .slice(filesIndex + '/files/'.length) + .split('?')[0] + .split('#')[0]; + const [chatId, fileId] = rest.split('/'); + + if (!chatId || !fileId) { + return null; + } + + return { chatId, fileId }; + }; + + const handleFileDownload = async (e: React.MouseEvent) => { + e.preventDefault(); + + if (!message.file_info) { + return; + } + + const downloadBlob = (blob: Blob) => { + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = message.file_info?.filename || 'download'; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 0); + }; + + const fileUrl = message.file_info.url; + if (fileUrl.startsWith('data:')) { + try { + const response = await fetch(fileUrl); + const blob = await response.blob(); + downloadBlob(blob); + } catch (error) { + console.error('Failed to download image data URL:', error); + } + return; + } + + const fileRef = extractFileRef(fileUrl); + if (!fileRef) { + console.error('Unsupported file URL:', fileUrl); + return; + } + + try { + const response = await fetch(buildDownloadUrl('/api/download-file'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + DownloadFile: { + chat_id: fileRef.chatId, + file_id: fileRef.fileId, + }, + }), + }); + if (!response.ok) { + throw new Error(`Download failed with status ${response.status}`); + } + + const json = await response.json(); + if (json && typeof json === 'object' && 'Err' in json) { + throw new Error((json as { Err: string }).Err); + } + + const payload = json && typeof json === 'object' && 'Ok' in json + ? (json as { Ok: unknown }).Ok + : json; + let mimeType = message.file_info.mime_type || 'application/octet-stream'; + let fileBytes: number[] | null = null; + + if (Array.isArray(payload)) { + if (payload.length === 2 && typeof payload[0] === 'string' && Array.isArray(payload[1])) { + mimeType = payload[0]; + fileBytes = payload[1] as number[]; + } else if (payload.every((value) => typeof value === 'number')) { + fileBytes = payload as number[]; + } + } + + if (!fileBytes) { + console.error('Unexpected file payload shape:', payload); + return; + } + + const blob = new Blob([new Uint8Array(fileBytes)], { type: mimeType }); + downloadBlob(blob); + } catch (error) { + console.error('Failed to download file:', error); + } + }; + + const handleLongPress = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + const rect = (e.target as HTMLElement).getBoundingClientRect(); + setMenuPosition({ x: rect.left, y: rect.top }); + setShowMenu(true); + }; + + const isAudioMessage = Boolean( + message.file_info && message.message_type === Chat.MessageType.VoiceNote, + ); + + useEffect(() => { + if (!isAudioMessage || !message.file_info) { + if (audioUrlRef.current && audioUrlRef.current.startsWith('blob:')) { + URL.revokeObjectURL(audioUrlRef.current); + } + audioUrlRef.current = null; + setAudioUrl(null); + return; + } + + const fileUrl = message.file_info.url; + if (fileUrl.startsWith('data:')) { + setAudioUrl(fileUrl); + return; + } + + const fileRef = extractFileRef(fileUrl); + if (!fileRef) { + return; + } + + let cancelled = false; + const fetchAudio = async () => { + try { + const response = await fetch(buildDownloadUrl('/api/download-file'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + DownloadFile: { + chat_id: fileRef.chatId, + file_id: fileRef.fileId, + }, + }), + }); + if (!response.ok) { + throw new Error(`Download failed with status ${response.status}`); + } + + const json = await response.json(); + if (json && typeof json === 'object' && 'Err' in json) { + throw new Error((json as { Err: string }).Err); + } + const payload = + json && typeof json === 'object' && 'Ok' in json ? (json as { Ok: unknown }).Ok : json; + let mimeType = message.file_info?.mime_type || 'audio/webm'; + let fileBytes: number[] | null = null; + + if (Array.isArray(payload)) { + if (payload.length === 2 && typeof payload[0] === 'string' && Array.isArray(payload[1])) { + mimeType = payload[0]; + fileBytes = payload[1] as number[]; + } else if (payload.every((value) => typeof value === 'number')) { + fileBytes = payload as number[]; + } + } + + if (!fileBytes || cancelled) { + return; + } + + const blob = new Blob([new Uint8Array(fileBytes)], { type: mimeType }); + const objectUrl = URL.createObjectURL(blob); + if (audioUrlRef.current && audioUrlRef.current.startsWith('blob:')) { + URL.revokeObjectURL(audioUrlRef.current); + } + audioUrlRef.current = objectUrl; + setAudioUrl(objectUrl); + } catch (error) { + console.error('Failed to load audio:', error); + } + }; + + fetchAudio(); + + return () => { + cancelled = true; + }; + }, [isAudioMessage, message.file_info?.url, message.file_info?.mime_type, message.message_type]); + + // Swipe handlers for swipe-to-reply + const handleTouchStart = (e: React.TouchEvent) => { + startXRef.current = e.touches[0].clientX; + startYRef.current = e.touches[0].clientY; + touchStartTimeRef.current = Date.now(); + setIsSwiping(false); + + // Start long press timer for iOS + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + } + + longPressTimerRef.current = setTimeout(() => { + // Trigger long press after 500ms + const touch = e.touches[0]; + const rect = messageRef.current?.getBoundingClientRect(); + if (rect) { + setMenuPosition({ x: touch.clientX, y: touch.clientY }); + setShowMenu(true); + // Haptic feedback for long press + if ('vibrate' in navigator) { + navigator.vibrate(10); + } + } + }, 500); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + const currentX = e.touches[0].clientX; + const currentY = e.touches[0].clientY; + const deltaX = currentX - startXRef.current; + const deltaY = Math.abs(currentY - startYRef.current); + + // Cancel long press if user moves finger too much + if (Math.abs(deltaX) > 10 || deltaY > 10) { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + } + + // Only trigger swipe if horizontal movement is greater than vertical + if (Math.abs(deltaX) > 10 && deltaY < 50) { + setIsSwiping(true); + // Limit swipe distance + const limitedDeltaX = Math.min(Math.max(deltaX, -80), 80); + setSwipeX(limitedDeltaX); + + // Add haptic feedback when reaching threshold + if (Math.abs(limitedDeltaX) >= 60 && 'vibrate' in navigator) { + navigator.vibrate(10); + } + } + }; + + const handleTouchEnd = () => { + // Clear long press timer + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + + // Check if it was a quick tap (less than 200ms) to prevent accidental swipe-to-reply + const touchDuration = Date.now() - touchStartTimeRef.current; + + if (Math.abs(swipeX) >= 60 && touchDuration > 200) { + // Trigger reply action + setReplyingTo(message); + // Add visual feedback + if (messageRef.current) { + messageRef.current.classList.add('reply-triggered'); + setTimeout(() => { + messageRef.current?.classList.remove('reply-triggered'); + }, 300); + } + } + setSwipeX(0); + setIsSwiping(false); + }; + + const handleReaction = async (emoji: string) => { + try { + const ourNode = (window as any).our?.node; + console.log('[REACTION] Our node:', ourNode); + console.log('[REACTION] Message reactions:', message.reactions); + + // Check if user already reacted with this emoji + const existingReaction = message.reactions?.find( + r => r.user === ourNode && r.emoji === emoji + ); + + console.log('[REACTION] Existing reaction found:', existingReaction); + console.log('[REACTION] Chat ID:', activeChat?.id, 'Message ID:', message.id); + + if (existingReaction) { + console.log('[REACTION] Removing reaction:', emoji); + const result = await remove_reaction({ + chat_id: activeChat?.id || '', + message_id: message.id, + emoji + }); + console.log('[REACTION] Remove reaction result:', result); + } else { + console.log('[REACTION] Adding reaction:', emoji); + const result = await add_reaction({ + chat_id: activeChat?.id || '', + message_id: message.id, + emoji + }); + console.log('[REACTION] Add reaction result:', result); + } + + // WebSocket will handle the update + } catch (err) { + console.error('[REACTION] Error handling reaction:', err); + } + }; + + const groupReactions = () => { + const grouped: { [emoji: string]: string[] } = {}; + message.reactions?.forEach(reaction => { + if (!grouped[reaction.emoji]) { + grouped[reaction.emoji] = []; + } + grouped[reaction.emoji].push(reaction.user); + }); + return grouped; + }; + + // Render message content with markdown support + const normalizedContent = useMemo( + () => normalizeMessageContent(message.content), + [message.content], + ); + + const renderMessageContent = useMemo(() => { + return ( + { + // Allow hw:// protocol links to pass through unchanged + if (url.startsWith('hw://')) { + return url; + } + // For other URLs, return as-is (React Markdown will handle security) + return url; + }} + components={{ + // Custom link rendering + a: ({ href, children }) => { + const imageRegex = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i; + const isHwProtocol = href?.startsWith('hw://'); + + // Check if link is an image URL + if (href && imageRegex.test(href) && settings?.show_images) { + return ( + + ); + } + + // hw:// protocol links - let hw-protocol-watcher handle them + if (isHwProtocol) { + return ( + + {children} + + ); + } + + // Regular link + return ( + + {children} + + ); + }, + // Custom paragraph rendering to handle spacing + p: ({ children }) => ( +

{children}

+ ), + // Custom code rendering + code: ({ children, ...props }) => { + const inline = !('className' in props && typeof props.className === 'string' && props.className.includes('language-')); + if (inline) { + return ( + + {children} + + ); + } + return ( +
+                {children}
+              
+ ); + }, + // Custom list rendering + ul: ({ children }) => ( +
    {children}
+ ), + ol: ({ children }) => ( +
    {children}
+ ), + // Custom blockquote rendering + blockquote: ({ children }) => ( +
+ {children} +
+ ), + // Custom heading rendering + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), + // Custom image rendering + img: ({ src, alt }) => { + if (!settings?.show_images) return null; + return ( + {alt} + ); + }, + }} + > + {normalizedContent} +
+ ); + }, [normalizedContent, settings?.show_images, isOwn]); + + return ( + <> +
+ {message.reply_to && ( +
{ + // Scroll to the original message + const element = document.getElementById(`message-${message.reply_to}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Add a highlight animation + element.classList.add('highlight'); + setTimeout(() => element.classList.remove('highlight'), 2000); + } + }} + style={{ cursor: 'pointer' }} + > +
↩ Reply
+
+ {activeChat?.messages.find(m => m.id === message.reply_to)?.content || 'Message not found'} +
+
+ )} + +
+ {/* If this is a file/image message with file info, show it specially */} + {isAudioMessage && message.file_info ? ( +
+ {audioUrl ? ( +
+ ) : message.file_info && message.message_type === 'Image' && settings?.show_images ? ( +
+ {message.file_info.filename} +
+ + {message.file_info.filename} + +
+ {(message.file_info.size / 1024).toFixed(1)} KB +
+
+
+ ) : message.file_info && message.message_type === 'File' ? ( +
+ + {message.file_info.filename} + +
+ {(message.file_info.size / 1024).toFixed(1)} KB +
+
+ ) : ( + renderMessageContent + )} +
+ +
+ {formatTime(message.timestamp)} + {isOwn && ( + {getStatusIcon()} + )} +
+ + {message.reactions && message.reactions.length > 0 && ( +
+ {Object.entries(groupReactions()).map(([emoji, users]) => ( + + ))} +
+ )} +
+ + {showMenu && ( + setShowMenu(false)} + /> + )} + + ); +}; + +export default Message; diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/MessageInput.css b/hyperdrive/packages/homepage/ui/src/components/Chat/MessageInput.css new file mode 100644 index 000000000..1f4133efc --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/MessageInput.css @@ -0,0 +1,263 @@ +.message-input-wrapper { + background: rgba(5, 10, 24, 0.85); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +@media (prefers-color-scheme: light) { + .message-input-wrapper { + background: rgba(245, 246, 248, 0.85); + border-top: 1px solid rgba(0, 0, 0, 0.05); + } +} + +/* Reply/Edit preview bars */ +.reply-preview, +.edit-preview { + padding: 10px 16px; + background: var(--surface); + border-bottom: 1px solid var(--border-color); + animation: preview-slide-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + transform-origin: bottom center; +} + +@keyframes preview-slide-in { + 0% { + opacity: 0; + transform: translateY(10px) scaleY(0.9); + max-height: 0; + } + 100% { + opacity: 1; + transform: translateY(0) scaleY(1); + max-height: 100px; + } +} + +.edit-preview { + border-left: 3px solid var(--primary-color); +} + +.reply-preview { + border-left: 3px solid var(--primary-color); +} + +.reply-info, +.edit-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.reply-label, +.edit-label { + font-size: 12px; + color: var(--primary-color); + font-weight: 500; +} + +.cancel-reply, +.cancel-edit { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px 8px; + font-size: 18px; + line-height: 1; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease, color 0.2s ease; +} + +.cancel-reply:hover, +.cancel-edit:hover { + background: var(--surface-hover, rgba(255, 255, 255, 0.05)); + color: var(--primary-color); +} + +.reply-content, +.edit-content { + font-size: 13px; + color: var(--text-primary); + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +/* Input container */ +.message-input-container { + display: flex; + align-items: flex-end; + gap: 10px; + padding: 12px 16px; + padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); +} + +.message-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.message-action-button { + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid var(--border-color); + background: var(--surface); + font-size: 18px; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; +} + +.message-action-button:hover { + background: var(--surface-hover, rgba(255, 255, 255, 0.1)); + border-color: var(--primary-color); + color: var(--primary-color); + transform: scale(1.05); +} + +.message-action-button:active { + transform: scale(0.92); +} + +.message-action-button:disabled { + opacity: 0.5; + cursor: default; + transform: scale(1); +} + +.message-action-button .material-symbols-outlined { + font-size: 20px; +} + +.message-input-container.editing { + background: rgba(204, 255, 0, 0.05); +} + +/* Text input */ +.message-input { + flex: 1; + min-height: 40px; + max-height: 120px; + padding: 10px 14px; + border: 1px solid var(--border-color); + border-radius: 20px; + background: var(--surface); + font-size: 15px; + color: var(--text-primary); + resize: none; + outline: none; + font-family: inherit; + transition: border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.message-input::placeholder { + color: var(--text-secondary); +} + +.message-input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(204, 255, 0, 0.15); +} + +/* Send button - Stitch lime green */ +.send-button { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--primary-color); + color: #000; + border: none; + font-size: 20px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), + opacity 0.15s ease, + box-shadow 0.15s ease; + box-shadow: 0 4px 12px rgba(204, 255, 0, 0.3); +} + +.send-button .material-symbols-outlined { + font-size: 22px; +} + +.send-button:not(:disabled):hover { + transform: scale(1.08); + box-shadow: 0 6px 16px rgba(204, 255, 0, 0.4); +} + +.send-button:not(:disabled):active { + transform: scale(0.9); +} + +.send-button:disabled { + opacity: 0.4; + cursor: default; + transform: scale(1); + box-shadow: none; +} + +/* Send pulse on input container */ +@keyframes input-send-pulse { + 0% { transform: scale(1); } + 30% { transform: scale(0.97); } + 100% { transform: scale(1); } +} + +.message-input-container.just-sent { + animation: input-send-pulse 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +/* Recording state */ +.message-input-container.recording .message-input { + border-color: #ff4444; +} + +.recording-indicator { + display: flex; + align-items: center; + gap: 8px; + color: #ff4444; + font-size: 14px; + font-weight: 500; +} + +.recording-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #ff4444; + animation: recording-pulse 1s ease-in-out infinite; +} + +@keyframes recording-pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(0.8); + } +} diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/MessageInput.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/MessageInput.tsx new file mode 100644 index 000000000..9adb6748a --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/MessageInput.tsx @@ -0,0 +1,178 @@ +import React, { useState, useRef, useEffect } from 'react'; +import * as Caller from '#caller-utils'; +import { useChatStore } from '../../store/chat'; +import FileUpload from './FileUpload'; +import VoiceNote from './VoiceNote'; +import './MessageInput.css'; + +interface MessageInputProps { + chatId: string; + onSendMessage?: () => void; +} + +const MessageInput: React.FC = ({ chatId, onSendMessage }) => { + const [message, setMessage] = useState(''); + const [showFileUpload, setShowFileUpload] = useState(false); + const [showVoiceNote, setShowVoiceNote] = useState(false); + const { sendMessage, replyingTo, setReplyingTo, editingMessage, setEditingMessage, editMessage } = useChatStore(); + const inputRef = useRef(null); + const containerRef = useRef(null); + + // Detect if user is on mobile device (avoid touch-enabled laptops) + const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); + + // Focus input when replying + useEffect(() => { + if (replyingTo) { + inputRef.current?.focus(); + } + }, [replyingTo]); + + // Focus input and populate when editing + useEffect(() => { + if (editingMessage) { + setMessage(editingMessage.content); + inputRef.current?.focus(); + } + }, [editingMessage]); + + const handleSubmit = async (e?: React.FormEvent) => { + e?.preventDefault(); + const messageText = message.trim(); + if (messageText) { + // Clear input immediately to prevent double send + setMessage(''); + + if (editingMessage) { + // Handle edit + const editId = editingMessage.id; + setEditingMessage(null); + if (messageText !== editingMessage.content) { + await editMessage(editId, messageText); + } + } else { + // Handle send + const replyToId = replyingTo?.id; + setReplyingTo(null); + await sendMessage(chatId, messageText, replyToId); + onSendMessage?.(); + // Trigger send pulse animation on input container + if (containerRef.current) { + containerRef.current.classList.add('just-sent'); + setTimeout(() => containerRef.current?.classList.remove('just-sent'), 350); + } + } + inputRef.current?.focus(); + } + }; + + const handleCancelEdit = () => { + setEditingMessage(null); + setMessage(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // On desktop: Enter sends, Shift+Enter adds newline + // On mobile: Enter adds newline, send button must be used + if (e.key === 'Enter' && !e.shiftKey && !isMobile) { + e.preventDefault(); + handleSubmit(); + } + }; + + const handleSendVoiceNote = async (payload: { base64: string; duration: number; mimeType: string }) => { + const replyToId = replyingTo?.id || null; + setReplyingTo(null); + await Caller.Chat.send_voice_note({ + chat_id: chatId, + audio_data: payload.base64, + duration: payload.duration, + reply_to: replyToId, + }); + onSendMessage?.(); + }; + + return ( +
+ {editingMessage && ( +
+
+ Editing message + +
+
{editingMessage.content}
+
+ )} + {replyingTo && !editingMessage && ( +
+
+ Replying to {replyingTo.sender} + +
+
{replyingTo.content}
+
+ )} + +
+ {!editingMessage && ( +
+ + +
+ )} +