├── .cargo └── config.toml ├── .github └── workflows │ ├── build.yml │ ├── clippy.yml.disabled │ └── matrix_pr_merge_notify.yml ├── .gitignore ├── .gitmodules ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml ├── river.iml ├── vcs.xml └── workspace.xml ├── .prettierrc ├── .vscode ├── settings.json └── tasks.json ├── .zed └── settings.json ├── CONVENTIONS.md ├── Cargo.toml ├── DEVNOTES.md ├── LICENSE ├── Makefile.toml ├── README.md ├── common ├── Cargo.toml └── src │ ├── chat_delegate.rs │ ├── crypto_values.rs │ ├── lib.rs │ ├── room_state.rs │ ├── room_state │ ├── ban.rs │ ├── configuration.rs │ ├── member.rs │ ├── member_info.rs │ ├── message.rs │ └── upgrade.rs │ ├── util.rs │ └── web_container.rs ├── contracts ├── room-contract │ ├── .rustc_info.json │ ├── Cargo.toml │ ├── src │ │ └── lib.rs │ └── wasm32-unknown-unknown │ │ └── CACHEDIR.TAG └── web-container-contract │ ├── Cargo.toml │ ├── freenet.toml │ ├── src │ └── lib.rs │ ├── tests │ └── integration_tests.rs │ └── web-container-tool │ ├── Cargo.toml │ └── src │ └── main.rs ├── delegates └── chat-delegate │ ├── Cargo.toml │ ├── README.md │ └── src │ ├── context.rs │ ├── handlers.rs │ ├── lib.rs │ ├── logging.rs │ ├── models.rs │ └── utils.rs ├── published-contract ├── contract-id.txt ├── web_container_contract.wasm └── webapp.parameters ├── screenshot-20241009.png ├── tests.rs └── ui ├── .gitignore ├── Cargo.toml ├── Dioxus.toml ├── assets ├── bulma.min.css ├── bulma.min.css.br ├── favicon.ico ├── favicon.ico.br ├── fontawesome │ ├── css │ │ ├── all.min.css │ │ └── all.min.css.br │ └── webfonts │ │ ├── fa-brands-400.ttf │ │ ├── fa-brands-400.ttf.br │ │ ├── fa-brands-400.woff2 │ │ ├── fa-brands-400.woff2.br │ │ ├── fa-regular-400.ttf │ │ ├── fa-regular-400.ttf.br │ │ ├── fa-regular-400.woff2 │ │ ├── fa-regular-400.woff2.br │ │ ├── fa-solid-900.ttf │ │ ├── fa-solid-900.ttf.br │ │ ├── fa-solid-900.woff2 │ │ ├── fa-solid-900.woff2.br │ │ ├── fa-v4compatibility.ttf │ │ ├── fa-v4compatibility.ttf.br │ │ ├── fa-v4compatibility.woff2 │ │ └── fa-v4compatibility.woff2.br ├── freenet_logo.svg ├── freenet_logo.svg.br ├── main.css ├── main.css.br └── river_logo.svg ├── build.rs ├── freenet.toml ├── mockup.html └── src ├── components.rs ├── components ├── app.rs ├── app │ ├── chat_delegate.rs │ ├── freenet_api.rs │ ├── freenet_api │ │ ├── connection_manager.rs │ │ ├── constants.rs │ │ ├── error.rs │ │ ├── freenet_synchronizer.rs │ │ ├── response_handler.rs │ │ ├── response_handler │ │ │ ├── get_response.rs │ │ │ ├── put_response.rs │ │ │ ├── subscribe_response.rs │ │ │ ├── update_notification.rs │ │ │ └── update_response.rs │ │ └── room_synchronizer.rs │ └── sync_info.rs ├── conversation.rs ├── conversation │ ├── message_input.rs │ └── not_member_notification.rs ├── members.rs ├── members │ ├── invite_member_modal.rs │ ├── member_info_modal.rs │ └── member_info_modal │ │ ├── ban_button.rs │ │ ├── invited_by_field.rs │ │ └── nickname_field.rs ├── room_list.rs └── room_list │ ├── create_room_modal.rs │ ├── edit_room_modal.rs │ ├── receive_invitation_modal.rs │ └── room_name_field.rs ├── constants.rs ├── example_data.rs ├── invites.rs ├── main.rs ├── pending_invites.rs ├── room_data.rs ├── util.rs └── util ├── ecies.rs └── name_gen.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.wasm32-unknown-unknown] 2 | rustflags = ["--cfg", "getrandom_backend=\"custom\""] 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | - master 9 | - develop 10 | - 'releases/**' 11 | 12 | env: 13 | CARGO_TERM_COLOR: always 14 | 15 | jobs: 16 | build: 17 | runs-on: freenet-default-runner 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | with: 22 | submodules: recursive 23 | 24 | - name: Install Rust 25 | uses: dtolnay/rust-toolchain@stable 26 | with: 27 | targets: wasm32-unknown-unknown 28 | 29 | - name: Cache cargo 30 | uses: actions/cache@v3 31 | with: 32 | path: | 33 | ~/.cargo/registry 34 | ~/.cargo/git 35 | target/ 36 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 37 | restore-keys: | 38 | ${{ runner.os }}-cargo- 39 | 40 | - name: Cache cargo-make 41 | uses: actions/cache@v3 42 | with: 43 | path: ~/.cargo/bin/cargo-make 44 | key: ${{ runner.os }}-cargo-make 45 | 46 | - name: Install cargo-make 47 | uses: actions-rs/cargo@v1 48 | with: 49 | command: install 50 | args: --force cargo-make 51 | 52 | - name: Install cargo-binstall 53 | uses: taiki-e/install-action@cargo-binstall 54 | 55 | - name: Install Dioxus CLI 56 | run: cargo binstall -y dioxus-cli 57 | 58 | - name: Build Project 59 | run: cargo make build --offline 60 | -------------------------------------------------------------------------------- /.github/workflows/clippy.yml.disabled: -------------------------------------------------------------------------------- 1 | name: Clippy 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | - master 9 | - develop 10 | - 'releases/**' 11 | 12 | env: 13 | CARGO_TERM_COLOR: always 14 | 15 | jobs: 16 | clippy: 17 | runs-on: freenet-default-runner 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | with: 22 | submodules: recursive 23 | 24 | - name: Install Rust 25 | uses: dtolnay/rust-toolchain@stable 26 | with: 27 | components: clippy 28 | targets: wasm32-unknown-unknown 29 | 30 | - name: Cache cargo 31 | uses: actions/cache@v3 32 | with: 33 | path: | 34 | ~/.cargo/registry 35 | ~/.cargo/git 36 | target/ 37 | key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }} 38 | restore-keys: | 39 | ${{ runner.os }}-cargo-clippy- 40 | 41 | - name: Install cargo-make 42 | uses: davidB/rust-cargo-make@v1 43 | 44 | - name: Run Clippy 45 | run: cargo make clippy 46 | -------------------------------------------------------------------------------- /.github/workflows/matrix_pr_merge_notify.yml: -------------------------------------------------------------------------------- 1 | name: Notify Matrix on PR Merge 2 | on: 3 | pull_request: 4 | types: 5 | - closed 6 | 7 | jobs: 8 | notify_matrix: 9 | runs-on: ubuntu-latest 10 | if: github.event.pull_request.merged == true 11 | steps: 12 | - name: Send message to Matrix 13 | uses: olabiniV2/matrix-message@v0.0.1 14 | with: 15 | room_id: ${{ secrets.MATRIX_ROOM_ID }} 16 | access_token: ${{ secrets.MATRIX_ACCESS_TOKEN }} 17 | subject: "Pull Request Merged" 18 | message: "🤖 [Pull Request #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}](${{ github.event.pull_request.html_url }}) (created by [${{ github.event.pull_request.user.login }}](${{ github.event.pull_request.user.html_url }})) has been merged by [${{ github.event.pull_request.merged_by.login }}](${{ github.event.pull_request.merged_by.html_url }}) into the [${{ github.repository }}](${{ github.event.repository.html_url }}) repository." 19 | server: "matrix.org" 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | aider_stdlib_map.md 2 | 3 | # Generated by Cargo 4 | # will have compiled files and executables 5 | debug/ 6 | target/ 7 | 8 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 9 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 10 | Cargo.lock 11 | 12 | # These are backup files generated by rustfmt 13 | **/*.rs.bk 14 | 15 | # MSVC Windows builds of rustc generate these, which store debugging information 16 | *.pdb 17 | 18 | # RustRover 19 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 20 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 21 | # and can be added to the global gitignore or merged into this file. For a more nuclear 22 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 23 | #.idea/ 24 | .aider* 25 | .aider.chat.history.md 26 | .env 27 | 28 | # Keep published contract files 29 | !published-contract/ 30 | 31 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/.gitmodules -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 14 | 15 | 22 | 23 | 26 | 27 | 34 | 35 | 42 | 43 | 50 | 51 | 56 | 57 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/river.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "proseWrap": "always" 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.diagnostics.enable": false 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Build with dx in ui directory", 6 | "type": "shell", 7 | "command": "dx build --platform web", 8 | "options": { 9 | "cwd": "${workspaceFolder}/ui" 10 | }, 11 | "problemMatcher": { 12 | "owner": "rust", 13 | "fileLocation": ["relative", "${workspaceFolder}"], 14 | "pattern": [ 15 | { 16 | "regexp": "^(.+):(\\d+):(\\d+):\\s+(error|warning):\\s+(.*)$", 17 | "file": 1, 18 | "line": 2, 19 | "column": 3, 20 | "severity": 4, 21 | "message": 5 22 | } 23 | ] 24 | }, 25 | "group": { 26 | "kind": "build", 27 | "isDefault": true 28 | } 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.zed/settings.json: -------------------------------------------------------------------------------- 1 | // Folder-specific settings 2 | // 3 | // For a full list of overridable settings, and general information on folder-specific settings, 4 | // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 | { 6 | "lsp": { 7 | "rust-analyzer": { 8 | "initialization_options": { 9 | "rust": { 10 | "analyzerTargetDir": "contracts/room-contract" 11 | } 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /CONVENTIONS.md: -------------------------------------------------------------------------------- 1 | # River Coding Conventions 2 | 3 | - Keep files relatively short, ideally less than 200 lines. 4 | 5 | - Organize files top-down, highest level functions / structs first. 6 | - DO NOT CREATE mod.rs FILES, instead use the flat style for modules (ie. use foo.rs instead of foo/mod.rs) 7 | - If you're having weird rsx issues read https://dioxuslabs.com/learn/0.6/guide/rsx/ and 8 | https://dioxuslabs.com/learn/0.6/essentials/rsx/# 9 | - When working with Dioxus signals, avoid nested borrows of the same signal (e.g., don't call 10 | `write()` on a signal while already holding a `read()` reference). Instead, extract needed values 11 | into local variables before performing write operations. 12 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "common", 4 | "ui", 5 | "contracts/room-contract", 6 | "contracts/web-container-contract", 7 | "contracts/web-container-contract/web-container-tool", 8 | "delegates/chat-delegate", 9 | ] 10 | resolver = "2" 11 | 12 | [workspace.dependencies] 13 | # Serialization 14 | ciborium = "0.2.2" 15 | serde = { version = "1.0.130", features = ["derive"] } 16 | 17 | # Cryptography 18 | ed25519-dalek = { version = "2.1.1", default-features = false } 19 | blake3 = { version = "1.5.3", features = ["serde"] } 20 | x25519-dalek = { version = "2.0.0", features = ["static_secrets"] } 21 | curve25519-dalek = { version = "4.1.3" } 22 | sha2 = "0.10.8" 23 | aes-gcm = { version = "0.10.3", features = ["std"] } 24 | bs58 = "0.5.1" 25 | 26 | # Utilities 27 | byteorder = "1.5.0" 28 | rand = { version = "0.8.5", features = ["getrandom"], default-features = true } 29 | base64 = "0.22.1" 30 | once_cell = "1.18.0" 31 | data-encoding = "2.3.3" 32 | tracing = "0.1.40" 33 | chrono = { version = "0.4", features = ["serde"] } 34 | 35 | # Web-related 36 | web-sys = { version = "0.3.64", features = ["HtmlInputElement", "WindowClient", "Navigator", "Window", "UrlSearchParams", "Location"] } 37 | wasm-bindgen = "0.2.73" 38 | wasm-bindgen-futures = "0.4.43" 39 | futures-timer = "3.0.2" 40 | 41 | # Internal dependencies 42 | river-common = { path = "common", package = "river-common", default-features = true } 43 | 44 | # Freenet dependencies 45 | freenet-scaffold = "0.2.1" 46 | freenet-scaffold-macro = "0.2.1" 47 | freenet-stdlib = { version = "0.1.5", features = ["contract"] } 48 | 49 | [workspace.package] 50 | version = "0.1.0" 51 | edition = "2021" 52 | 53 | [profile.release] 54 | opt-level = 'z' # Optimize for size 55 | lto = true # Enable Link Time Optimization 56 | codegen-units = 1 # Reduce number of codegen units to increase optimizations 57 | panic = 'abort' # Abort on panic 58 | strip = true # Strip symbols from binary 59 | 60 | # Optimize dependencies in release mode 61 | [profile.release.package."*"] 62 | opt-level = 'z' # Optimize all dependencies for size as well 63 | 64 | [profile.wasm-dev] 65 | inherits = "dev" 66 | opt-level = 1 67 | 68 | [profile.server-dev] 69 | inherits = "dev" 70 | 71 | [profile.android-dev] 72 | inherits = "dev" 73 | 74 | [workspace.metadata.dx] 75 | toolchain = "nightly" 76 | 77 | # We need to pin specific versions to resolve dependency conflicts 78 | -------------------------------------------------------------------------------- /DEVNOTES.md: -------------------------------------------------------------------------------- 1 | # River Chat Application Overview 2 | 3 | River is a decentralized chat application built on Freenet with the following key characteristics: 4 | 5 | ## Architecture 6 | 7 | - **Frontend:** Dioxus-based WebAssembly UI running in the browser 8 | - **Backend:** Freenet network for decentralized storage and communication 9 | - **Deployment:** The application is packaged, signed, and published as a Freenet contract 10 | - **Communication:** Uses WebSocket API to interact with the local Freenet node 11 | 12 | ## Key Components 13 | 14 | 1. **FreenetApiSynchronizer** – Manages WebSocket communication with Freenet 15 | 2. **Room State Management** – Implements a commutative monoid pattern for order-agnostic state 16 | updates 17 | 3. **Invitation System** – Allows users to invite others to chat rooms 18 | 4. **Cryptographic Security** – Uses ed25519 for signatures and authentication 19 | 20 | ## Implementation Details 21 | 22 | - Uses a comprehensive logging system for debugging WebSocket interactions 23 | - State updates are designed to be commutative (order-independent) 24 | - Handles both full state updates and delta updates 25 | - Implements proper error handling and status reporting 26 | 27 | ## Deployment Process 28 | 29 | 1. **Build the UI:** `cargo make build-ui` 30 | 2. **Compress the webapp:** `cargo make compress-webapp` 31 | 3. **Sign the webapp:** `cargo make sign-webapp` 32 | 4. **Publish to Freenet:** `cargo make publish-river` 33 | 34 | ## Testing Challenges 35 | 36 | - Testing requires the full deployment pipeline 37 | - Cannot easily test components in isolation 38 | - Need to publish to Freenet and access via the Freenet web interface 39 | 40 | ## Current Status 41 | 42 | - Core functionality is implemented 43 | - WebSocket API integration is ready for testing 44 | - Invitation system is implemented but untested 45 | - Limited runway to prove the concept works 46 | -------------------------------------------------------------------------------- /common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "river-common" 3 | version.workspace = true 4 | edition.workspace = true 5 | 6 | [dependencies] 7 | # Serialization 8 | ciborium.workspace = true 9 | serde.workspace = true 10 | 11 | # Cryptography 12 | ed25519-dalek = { workspace = true, default-features = false, features = ["alloc", "serde"] } 13 | blake3.workspace = true 14 | bs58.workspace = true 15 | 16 | # Utilities 17 | rand = { workspace = true, optional = true } # Make `rand` optional 18 | getrandom = { version = "0.2.15", optional = true, default-features = false } 19 | base64.workspace = true 20 | data-encoding.workspace = true 21 | 22 | # Internal dependencies 23 | freenet-scaffold.workspace = true 24 | freenet-scaffold-macro.workspace = true 25 | freenet-stdlib.workspace = true 26 | 27 | [dev-dependencies] 28 | rand.workspace = true 29 | ed25519-dalek = { workspace = true, features = ["rand_core"] } 30 | -------------------------------------------------------------------------------- /common/src/chat_delegate.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | /// Messages sent from the App to the Chat Delegate 4 | #[derive(Debug, Clone, Serialize, Deserialize)] 5 | pub enum ChatDelegateRequestMsg { 6 | StoreRequest { 7 | key: ChatDelegateKey, 8 | value: Vec, 9 | }, 10 | GetRequest { 11 | key: ChatDelegateKey, 12 | }, 13 | DeleteRequest { 14 | key: ChatDelegateKey, 15 | }, 16 | ListRequest, 17 | } 18 | 19 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] 20 | pub struct ChatDelegateKey(pub Vec); 21 | 22 | impl ChatDelegateKey { 23 | pub fn new(key: Vec) -> Self { 24 | Self(key) 25 | } 26 | 27 | pub fn as_bytes(&self) -> &[u8] { 28 | &self.0 29 | } 30 | } 31 | 32 | /// Responses sent from the Chat Delegate to the App 33 | #[derive(Debug, Clone, Serialize, Deserialize)] 34 | pub enum ChatDelegateResponseMsg { 35 | GetResponse { 36 | key: ChatDelegateKey, 37 | value: Option>, 38 | }, 39 | ListResponse { 40 | keys: Vec, 41 | }, 42 | StoreResponse { 43 | key: ChatDelegateKey, 44 | value_size: usize, 45 | result: Result<(), String>, 46 | }, 47 | DeleteResponse { 48 | key: ChatDelegateKey, 49 | result: Result<(), String>, 50 | }, 51 | } 52 | -------------------------------------------------------------------------------- /common/src/crypto_values.rs: -------------------------------------------------------------------------------- 1 | use bs58; 2 | use ed25519_dalek::{Signature, SigningKey, VerifyingKey}; 3 | use std::str::FromStr; 4 | 5 | #[derive(Debug, Clone, PartialEq)] 6 | pub enum CryptoValue { 7 | VerifyingKey(VerifyingKey), 8 | SigningKey(SigningKey), 9 | Signature(Signature), 10 | } 11 | 12 | impl CryptoValue { 13 | const VERSION_PREFIX: &'static str = "river:v1"; 14 | 15 | pub fn to_encoded_string(&self) -> String { 16 | let type_str = match self { 17 | CryptoValue::VerifyingKey(_) => "vk", 18 | CryptoValue::SigningKey(_) => "sk", 19 | CryptoValue::Signature(_) => "sig", 20 | }; 21 | 22 | let key_bytes = match self { 23 | CryptoValue::VerifyingKey(vk) => vk.to_bytes().to_vec(), 24 | CryptoValue::SigningKey(sk) => sk.to_bytes().to_vec(), 25 | CryptoValue::Signature(sig) => sig.to_bytes().to_vec(), 26 | }; 27 | 28 | format!( 29 | "{}:{}:{}", 30 | Self::VERSION_PREFIX, 31 | type_str, 32 | bs58::encode(key_bytes).into_string() 33 | ) 34 | } 35 | 36 | pub fn from_encoded_string(s: &str) -> Result { 37 | let parts: Vec<&str> = s.split(':').collect(); 38 | if parts.len() != 4 || format!("{}:{}", parts[0], parts[1]) != Self::VERSION_PREFIX { 39 | return Err("Invalid format".to_string()); 40 | } 41 | 42 | let decoded = bs58::decode(parts[3]) 43 | .into_vec() 44 | .map_err(|e| format!("Base58 decode error: {}", e))?; 45 | 46 | match parts[2] { 47 | "vk" => { 48 | let bytes: [u8; 32] = decoded 49 | .try_into() 50 | .map_err(|_| "Invalid verifying key length".to_string())?; 51 | VerifyingKey::from_bytes(&bytes) 52 | .map(CryptoValue::VerifyingKey) 53 | .map_err(|e| format!("Invalid verifying key: {}", e)) 54 | } 55 | "sk" => { 56 | let bytes: [u8; 32] = decoded 57 | .try_into() 58 | .map_err(|_| "Invalid signing key length".to_string())?; 59 | Ok(CryptoValue::SigningKey(SigningKey::from_bytes(&bytes))) 60 | } 61 | "sig" => { 62 | let bytes: [u8; 64] = decoded 63 | .try_into() 64 | .map_err(|_| "Invalid signature length".to_string())?; 65 | Ok(CryptoValue::Signature(Signature::from_bytes(&bytes))) 66 | } 67 | _ => Err("Unknown key type".to_string()), 68 | } 69 | } 70 | } 71 | 72 | impl FromStr for CryptoValue { 73 | type Err = String; 74 | 75 | fn from_str(s: &str) -> Result { 76 | // If string already contains prefix, use it directly 77 | if s.starts_with(Self::VERSION_PREFIX) { 78 | Self::from_encoded_string(s) 79 | } else { 80 | // Otherwise treat as raw base58 data 81 | let decoded = bs58::decode(s) 82 | .into_vec() 83 | .map_err(|e| format!("Base58 decode error: {}", e))?; 84 | 85 | // Try to interpret as signing key first 86 | if decoded.len() == 32 { 87 | let bytes: [u8; 32] = decoded 88 | .try_into() 89 | .map_err(|_| "Invalid signing key length".to_string())?; 90 | Ok(CryptoValue::SigningKey(SigningKey::from_bytes(&bytes))) 91 | } else { 92 | Err("Invalid key length".to_string()) 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /common/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod chat_delegate; 2 | pub mod crypto_values; 3 | pub mod room_state; 4 | pub mod util; 5 | pub mod web_container; 6 | 7 | pub use room_state::ChatRoomStateV1; 8 | pub use web_container::WebContainerMetadata; 9 | -------------------------------------------------------------------------------- /common/src/room_state.rs: -------------------------------------------------------------------------------- 1 | pub mod ban; 2 | pub mod configuration; 3 | pub mod member; 4 | pub mod member_info; 5 | pub mod message; 6 | pub mod upgrade; 7 | 8 | use crate::room_state::ban::BansV1; 9 | use crate::room_state::configuration::AuthorizedConfigurationV1; 10 | use crate::room_state::member::{MemberId, MembersV1}; 11 | use crate::room_state::member_info::MemberInfoV1; 12 | use crate::room_state::message::MessagesV1; 13 | use crate::room_state::upgrade::OptionalUpgradeV1; 14 | use ed25519_dalek::VerifyingKey; 15 | use freenet_scaffold_macro::composable; 16 | use serde::{Deserialize, Serialize}; 17 | 18 | #[composable] 19 | #[derive(Serialize, Deserialize, Clone, Default, PartialEq, Debug)] 20 | pub struct ChatRoomStateV1 { 21 | // WARNING: The order of these fields is important for the purposes of the #[composable] macro. 22 | // `configuration` must be first, followed by `bans`, `members`, `member_info_modal`, and then `recent_messages`. 23 | // This is due to interdependencies between the fields and the order in which they must be applied in 24 | // the `apply_delta` function. DO NOT reorder fields without fully understanding the implications. 25 | /// Configures things like maximum message length, can be updated by the owner. 26 | pub configuration: AuthorizedConfigurationV1, 27 | 28 | /// A list of recently banned members, a banned member can't be present in the 29 | /// members list and will be removed from it ifc necessary. 30 | pub bans: BansV1, 31 | 32 | /// The members in the chat room along with who invited them 33 | pub members: MembersV1, 34 | 35 | /// Metadata about members like their nickname, can be updated by members themselves. 36 | pub member_info: MemberInfoV1, 37 | 38 | /// The most recent messages in the chat room, the number is limited by the room configuration. 39 | pub recent_messages: MessagesV1, 40 | 41 | /// If this contract has been replaced by a new contract this will contain the new contract address. 42 | /// This can only be set by the owner. 43 | pub upgrade: OptionalUpgradeV1, 44 | } 45 | 46 | #[derive(Serialize, Deserialize, Clone, Default, PartialEq, Debug)] 47 | pub struct ChatRoomParametersV1 { 48 | pub owner: VerifyingKey, 49 | } 50 | 51 | impl ChatRoomParametersV1 { 52 | pub fn owner_id(&self) -> MemberId { 53 | self.owner.into() 54 | } 55 | } 56 | 57 | #[cfg(test)] 58 | mod tests { 59 | use super::*; 60 | use crate::room_state::configuration::Configuration; 61 | use ed25519_dalek::SigningKey; 62 | use std::fmt::Debug; 63 | 64 | #[test] 65 | fn test_state() { 66 | let (state, parameters, owner_signing_key) = create_empty_chat_room_state(); 67 | 68 | assert!( 69 | state.verify(&state, ¶meters).is_ok(), 70 | "Empty state should verify" 71 | ); 72 | 73 | // Test that the configuration can be updated 74 | let mut new_cfg = state.configuration.configuration.clone(); 75 | new_cfg.configuration_version += 1; 76 | new_cfg.max_recent_messages = 10; // Change from default of 100 to 10 77 | let new_cfg = AuthorizedConfigurationV1::new(new_cfg, &owner_signing_key); 78 | 79 | let mut cfg_modified_state = state.clone(); 80 | cfg_modified_state.configuration = new_cfg; 81 | test_apply_delta(state.clone(), cfg_modified_state, ¶meters); 82 | } 83 | 84 | fn test_apply_delta(orig_state: CS, modified_state: CS, parameters: &CS::Parameters) 85 | where 86 | CS: ComposableState + Clone + PartialEq + Debug, 87 | { 88 | let orig_verify_result = orig_state.verify(&orig_state, parameters); 89 | assert!( 90 | orig_verify_result.is_ok(), 91 | "Original state verification failed: {:?}", 92 | orig_verify_result.err() 93 | ); 94 | 95 | let modified_verify_result = modified_state.verify(&modified_state, parameters); 96 | assert!( 97 | modified_verify_result.is_ok(), 98 | "Modified state verification failed: {:?}", 99 | modified_verify_result.err() 100 | ); 101 | 102 | let delta = modified_state.delta( 103 | &orig_state, 104 | parameters, 105 | &orig_state.summarize(&orig_state, parameters), 106 | ); 107 | 108 | println!("Delta: {:?}", delta); 109 | 110 | let mut new_state = orig_state.clone(); 111 | let apply_delta_result = new_state.apply_delta(&orig_state, parameters, &delta); 112 | assert!( 113 | apply_delta_result.is_ok(), 114 | "Applying delta failed: {:?}", 115 | apply_delta_result.err() 116 | ); 117 | 118 | assert_eq!(new_state, modified_state); 119 | } 120 | fn create_empty_chat_room_state() -> (ChatRoomStateV1, ChatRoomParametersV1, SigningKey) { 121 | // Create a test room_state with a single member and two messages, one written by 122 | // the owner and one by the member - the member must be invited by the owner 123 | let rng = &mut rand::thread_rng(); 124 | let owner_signing_key = SigningKey::generate(rng); 125 | let owner_verifying_key = owner_signing_key.verifying_key(); 126 | 127 | let config = AuthorizedConfigurationV1::new(Configuration::default(), &owner_signing_key); 128 | 129 | ( 130 | ChatRoomStateV1 { 131 | configuration: config, 132 | bans: BansV1::default(), 133 | members: MembersV1::default(), 134 | member_info: MemberInfoV1::default(), 135 | recent_messages: MessagesV1::default(), 136 | upgrade: OptionalUpgradeV1(None), 137 | }, 138 | ChatRoomParametersV1 { 139 | owner: owner_verifying_key, 140 | }, 141 | owner_signing_key, 142 | ) 143 | } 144 | 145 | #[test] 146 | fn test_state_with_none_deltas() { 147 | let (state, parameters, owner_signing_key) = create_empty_chat_room_state(); 148 | 149 | // Create a modified room_state with no changes (all deltas should be None) 150 | let modified_state = state.clone(); 151 | 152 | // Apply the delta 153 | let summary = state.summarize(&state, ¶meters); 154 | let delta = modified_state.delta(&state, ¶meters, &summary); 155 | 156 | assert!( 157 | delta.is_none(), 158 | "Delta should be None when no changes are made" 159 | ); 160 | 161 | // Now, let's modify only one field and check if other deltas are None 162 | let mut partially_modified_state = state.clone(); 163 | let new_config = Configuration { 164 | configuration_version: 2, 165 | ..partially_modified_state.configuration.configuration.clone() 166 | }; 167 | partially_modified_state.configuration = 168 | AuthorizedConfigurationV1::new(new_config, &owner_signing_key); 169 | 170 | let summary = state.summarize(&state, ¶meters); 171 | let delta = partially_modified_state 172 | .delta(&state, ¶meters, &summary) 173 | .unwrap(); 174 | 175 | // Check that only the configuration delta is Some, and others are None 176 | assert!( 177 | delta.configuration.is_some(), 178 | "Configuration delta should be Some" 179 | ); 180 | assert!(delta.bans.is_none(), "Bans delta should be None"); 181 | assert!(delta.members.is_none(), "Members delta should be None"); 182 | assert!( 183 | delta.member_info.is_none(), 184 | "Member info delta should be None" 185 | ); 186 | assert!( 187 | delta.recent_messages.is_none(), 188 | "Recent messages delta should be None" 189 | ); 190 | assert!(delta.upgrade.is_none(), "Upgrade delta should be None"); 191 | 192 | // Apply the partial delta 193 | let mut new_state = state.clone(); 194 | new_state 195 | .apply_delta(&state, ¶meters, &Some(delta)) 196 | .unwrap(); 197 | 198 | assert_eq!( 199 | new_state, partially_modified_state, 200 | "State should be partially modified" 201 | ); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /common/src/room_state/upgrade.rs: -------------------------------------------------------------------------------- 1 | use crate::room_state::member::MemberId; 2 | use crate::room_state::ChatRoomParametersV1; 3 | use crate::util::{sign_struct, truncated_base64, verify_struct}; 4 | use crate::ChatRoomStateV1; 5 | use blake3::Hash; 6 | use ed25519_dalek::{Signature, SigningKey, VerifyingKey}; 7 | use freenet_scaffold::ComposableState; 8 | use serde::{Deserialize, Serialize}; 9 | use std::fmt; 10 | 11 | #[derive(Serialize, Deserialize, Clone, PartialEq, Debug, Default)] 12 | pub struct OptionalUpgradeV1(pub Option); 13 | 14 | #[derive(Serialize, Deserialize, Clone, PartialEq)] 15 | pub struct AuthorizedUpgradeV1 { 16 | pub upgrade: UpgradeV1, 17 | pub signature: Signature, 18 | } 19 | 20 | impl ComposableState for OptionalUpgradeV1 { 21 | type ParentState = ChatRoomStateV1; 22 | type Summary = Option; 23 | type Delta = AuthorizedUpgradeV1; 24 | type Parameters = ChatRoomParametersV1; 25 | 26 | fn verify( 27 | &self, 28 | _parent_state: &Self::ParentState, 29 | parameters: &Self::Parameters, 30 | ) -> Result<(), String> { 31 | if let Some(upgrade) = &self.0 { 32 | upgrade 33 | .validate(¶meters.owner) 34 | .map_err(|e| format!("Invalid signature: {}", e)) 35 | } else { 36 | Ok(()) 37 | } 38 | } 39 | 40 | fn summarize( 41 | &self, 42 | _parent_state: &Self::ParentState, 43 | _parameters: &Self::Parameters, 44 | ) -> Self::Summary { 45 | self.0.as_ref().map(|u| u.upgrade.version) 46 | } 47 | 48 | fn delta( 49 | &self, 50 | _parent_state: &Self::ParentState, 51 | _parameters: &Self::Parameters, 52 | old_state_summary: &Self::Summary, 53 | ) -> Option { 54 | match &self.0 { 55 | Some(upgrade) => { 56 | // If the upgrade has a higher version than the old room_state summary or of the old summary is None 57 | // then return the upgrade as a delta 58 | if old_state_summary.is_none_or(|old_version| upgrade.upgrade.version > old_version) 59 | { 60 | Some(upgrade.clone()) 61 | } else { 62 | None 63 | } 64 | } 65 | None => None, 66 | } 67 | } 68 | 69 | fn apply_delta( 70 | &mut self, 71 | _parent_state: &Self::ParentState, 72 | parameters: &Self::Parameters, 73 | delta: &Option, 74 | ) -> Result<(), String> { 75 | if let Some(delta) = delta { 76 | // Verify the delta before applying it 77 | delta 78 | .validate(¶meters.owner) 79 | .map_err(|e| format!("Invalid upgrade signature: {}", e))?; 80 | 81 | *self = OptionalUpgradeV1(Some(delta.clone())); 82 | } 83 | Ok(()) 84 | } 85 | } 86 | 87 | impl AuthorizedUpgradeV1 { 88 | pub fn new(upgrade: UpgradeV1, signing_key: &SigningKey) -> Self { 89 | Self { 90 | upgrade: upgrade.clone(), 91 | signature: sign_struct(&upgrade, signing_key), 92 | } 93 | } 94 | 95 | pub fn validate( 96 | &self, 97 | verifying_key: &VerifyingKey, 98 | ) -> Result<(), ed25519_dalek::SignatureError> { 99 | verify_struct(&self.upgrade, &self.signature, verifying_key) 100 | } 101 | } 102 | 103 | impl fmt::Debug for AuthorizedUpgradeV1 { 104 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 105 | f.debug_struct("AuthorizedUpgrade") 106 | .field("upgrade", &self.upgrade) 107 | .field( 108 | "signature", 109 | &format_args!("{}", truncated_base64(self.signature.to_bytes())), 110 | ) 111 | .finish() 112 | } 113 | } 114 | 115 | #[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] 116 | pub struct UpgradeV1 { 117 | pub owner_member_id: MemberId, 118 | pub version: u8, 119 | pub new_chatroom_address: Hash, 120 | } 121 | 122 | #[cfg(test)] 123 | mod tests { 124 | use super::*; 125 | use crate::room_state::member::MemberId; 126 | use ed25519_dalek::SigningKey; 127 | use freenet_scaffold::util::FastHash; 128 | use rand::rngs::OsRng; 129 | 130 | fn create_test_upgrade(owner_id: MemberId) -> UpgradeV1 { 131 | UpgradeV1 { 132 | owner_member_id: owner_id, 133 | version: 1, 134 | new_chatroom_address: Hash::from([0; 32]), 135 | } 136 | } 137 | 138 | #[test] 139 | fn test_authorized_upgrade_new_and_validate() { 140 | let signing_key = SigningKey::generate(&mut OsRng); 141 | let verifying_key = signing_key.verifying_key(); 142 | let owner_id = MemberId(FastHash(0)); 143 | 144 | let upgrade = create_test_upgrade(owner_id); 145 | let authorized_upgrade = AuthorizedUpgradeV1::new(upgrade.clone(), &signing_key); 146 | 147 | assert_eq!(authorized_upgrade.upgrade, upgrade); 148 | assert!(authorized_upgrade.validate(&verifying_key).is_ok()); 149 | 150 | // Test with wrong key 151 | let wrong_key = SigningKey::generate(&mut OsRng).verifying_key(); 152 | assert!(authorized_upgrade.validate(&wrong_key).is_err()); 153 | } 154 | 155 | #[test] 156 | fn test_optional_upgrade_verify() { 157 | let owner_signing_key = SigningKey::generate(&mut OsRng); 158 | let owner_verifying_key = owner_signing_key.verifying_key(); 159 | let owner_id = MemberId::from(&owner_verifying_key); 160 | 161 | let upgrade = create_test_upgrade(owner_id); 162 | let authorized_upgrade = AuthorizedUpgradeV1::new(upgrade, &owner_signing_key); 163 | 164 | let optional_upgrade = OptionalUpgradeV1(Some(authorized_upgrade)); 165 | 166 | let parent_state = ChatRoomStateV1::default(); 167 | let parameters = ChatRoomParametersV1 { 168 | owner: owner_verifying_key, 169 | }; 170 | 171 | // Verify that a valid upgrade passes verification 172 | assert!( 173 | optional_upgrade.verify(&parent_state, ¶meters).is_ok(), 174 | "Valid upgrade should pass verification" 175 | ); 176 | 177 | // Test with invalid signature 178 | let mut invalid_upgrade = optional_upgrade.clone(); 179 | if let Some(ref mut au) = invalid_upgrade.0 { 180 | au.signature = Signature::from_bytes(&[0; 64]); // Replace with an invalid signature 181 | } 182 | assert!( 183 | invalid_upgrade.verify(&parent_state, ¶meters).is_err(), 184 | "Upgrade with invalid signature should fail verification" 185 | ); 186 | 187 | // Test with None 188 | let none_upgrade = OptionalUpgradeV1(None); 189 | assert!( 190 | none_upgrade.verify(&parent_state, ¶meters).is_ok(), 191 | "None upgrade should pass verification" 192 | ); 193 | } 194 | 195 | #[test] 196 | fn test_optional_upgrade_summarize() { 197 | let signing_key = SigningKey::generate(&mut OsRng); 198 | let owner_id = MemberId(FastHash(0)); 199 | 200 | let upgrade = create_test_upgrade(owner_id); 201 | let authorized_upgrade = AuthorizedUpgradeV1::new(upgrade, &signing_key); 202 | 203 | let optional_upgrade = OptionalUpgradeV1(Some(authorized_upgrade)); 204 | 205 | let parent_state = ChatRoomStateV1::default(); 206 | let parameters = ChatRoomParametersV1 { 207 | owner: signing_key.verifying_key(), 208 | }; 209 | 210 | let summary = optional_upgrade.summarize(&parent_state, ¶meters); 211 | assert_eq!(summary, Some(1)); 212 | 213 | let none_upgrade = OptionalUpgradeV1(None); 214 | let none_summary = none_upgrade.summarize(&parent_state, ¶meters); 215 | assert_eq!(none_summary, None); 216 | } 217 | 218 | #[test] 219 | fn test_optional_upgrade_delta() { 220 | let signing_key = SigningKey::generate(&mut OsRng); 221 | let owner_id = MemberId(FastHash(0)); 222 | 223 | let upgrade = create_test_upgrade(owner_id); 224 | let authorized_upgrade = AuthorizedUpgradeV1::new(upgrade, &signing_key); 225 | 226 | let optional_upgrade = OptionalUpgradeV1(Some(authorized_upgrade.clone())); 227 | 228 | let parent_state = ChatRoomStateV1::default(); 229 | let parameters = ChatRoomParametersV1 { 230 | owner: signing_key.verifying_key(), 231 | }; 232 | 233 | let old_summary = None; 234 | let delta = optional_upgrade.delta(&parent_state, ¶meters, &old_summary); 235 | 236 | assert_eq!(delta, Some(authorized_upgrade)); 237 | 238 | let none_upgrade = OptionalUpgradeV1(None); 239 | let none_delta = none_upgrade.delta(&parent_state, ¶meters, &old_summary); 240 | assert_eq!(none_delta, None); 241 | } 242 | 243 | #[test] 244 | fn test_optional_upgrade_apply_delta() { 245 | let signing_key = SigningKey::generate(&mut OsRng); 246 | let owner_id = MemberId(FastHash(0)); 247 | 248 | let upgrade = create_test_upgrade(owner_id); 249 | let authorized_upgrade = AuthorizedUpgradeV1::new(upgrade, &signing_key); 250 | 251 | let mut optional_upgrade = OptionalUpgradeV1(None); 252 | 253 | let parent_state = ChatRoomStateV1::default(); 254 | let parameters = ChatRoomParametersV1 { 255 | owner: signing_key.verifying_key(), 256 | }; 257 | 258 | let delta = authorized_upgrade.clone(); 259 | assert!(optional_upgrade 260 | .apply_delta(&parent_state, ¶meters, &Some(delta.clone())) 261 | .is_ok()); 262 | assert_eq!(optional_upgrade, OptionalUpgradeV1(Some(delta))); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /common/src/util.rs: -------------------------------------------------------------------------------- 1 | use base64::{engine::general_purpose, Engine as _}; 2 | use data_encoding::BASE32; 3 | use ed25519_dalek::{Signature, SignatureError, Signer, SigningKey, Verifier, VerifyingKey}; 4 | use serde::Serialize; 5 | 6 | pub fn sign_struct(message: T, signing_key: &SigningKey) -> Signature { 7 | let mut data_to_sign = Vec::new(); 8 | ciborium::ser::into_writer(&message, &mut data_to_sign).expect("Serialization should not fail"); 9 | signing_key.sign(&data_to_sign) 10 | } 11 | 12 | pub fn verify_struct( 13 | message: &T, 14 | signature: &Signature, 15 | verifying_key: &VerifyingKey, 16 | ) -> Result<(), SignatureError> { 17 | let mut data_to_sign = Vec::new(); 18 | ciborium::ser::into_writer(message, &mut data_to_sign).expect("Serialization should not fail"); 19 | verifying_key.verify(&data_to_sign, signature) 20 | } 21 | 22 | pub fn truncated_base64>(data: T) -> String { 23 | let encoded = general_purpose::STANDARD_NO_PAD.encode(data); 24 | encoded.chars().take(10).collect() 25 | } 26 | 27 | pub fn truncated_base32(bytes: &[u8]) -> String { 28 | let encoded = BASE32.encode(bytes); 29 | encoded.chars().take(8).collect() 30 | } 31 | 32 | #[cfg(test)] 33 | mod tests { 34 | use super::*; 35 | use rand::rngs::OsRng; 36 | 37 | #[test] 38 | fn test_sign_verify_struct() { 39 | let mut csprng = OsRng; 40 | let signing_key = SigningKey::generate(&mut csprng); 41 | let verifying_key = signing_key.verifying_key(); 42 | 43 | let message = "Hello, World!"; 44 | let signature = sign_struct(message, &signing_key); 45 | assert!(verify_struct(&message, &signature, &verifying_key).is_ok()); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /common/src/web_container.rs: -------------------------------------------------------------------------------- 1 | use ed25519_dalek::Signature; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Serialize, Deserialize)] 5 | pub struct WebContainerMetadata { 6 | pub version: u32, 7 | pub signature: Signature, // Signature of web interface + version number 8 | } 9 | -------------------------------------------------------------------------------- /contracts/room-contract/.rustc_info.json: -------------------------------------------------------------------------------- 1 | {"rustc_fingerprint":10761947043259727164,"outputs":{"12016735552878863467":{"success":true,"status":"","code":0,"stdout":"rustc 1.87.0-nightly (227690a25 2025-03-16)\nbinary: rustc\ncommit-hash: 227690a258492c84ae9927d18289208d0180e62f\ncommit-date: 2025-03-16\nhost: x86_64-unknown-linux-gnu\nrelease: 1.87.0-nightly\nLLVM version: 20.1.0\n","stderr":""},"13822592305234237280":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/ian/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\nfmt_debug=\"full\"\noverflow_checks\npanic=\"unwind\"\nproc_macro\nrelocation_model=\"pic\"\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"x87\"\ntarget_has_atomic\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_has_atomic_equal_alignment=\"16\"\ntarget_has_atomic_equal_alignment=\"32\"\ntarget_has_atomic_equal_alignment=\"64\"\ntarget_has_atomic_equal_alignment=\"8\"\ntarget_has_atomic_equal_alignment=\"ptr\"\ntarget_has_atomic_load_store\ntarget_has_atomic_load_store=\"16\"\ntarget_has_atomic_load_store=\"32\"\ntarget_has_atomic_load_store=\"64\"\ntarget_has_atomic_load_store=\"8\"\ntarget_has_atomic_load_store=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_thread_local\ntarget_vendor=\"unknown\"\nub_checks\nunix\n","stderr":""}},"successes":{}} -------------------------------------------------------------------------------- /contracts/room-contract/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "room-contract" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | ciborium.workspace = true 8 | serde.workspace = true 9 | ed25519-dalek.workspace = true 10 | rand.workspace = true 11 | freenet-stdlib.workspace = true 12 | freenet-scaffold.workspace = true 13 | river-common.workspace = true 14 | getrandom = { version = "0.2.15", features = ["js"], default-features = false } 15 | 16 | [lib] 17 | crate-type = ["cdylib", "rlib"] 18 | 19 | [features] 20 | default = ["freenet-main-contract"] 21 | contract = ["freenet-stdlib/contract"] 22 | freenet-main-contract = [] 23 | trace = ["freenet-stdlib/trace"] 24 | -------------------------------------------------------------------------------- /contracts/room-contract/src/lib.rs: -------------------------------------------------------------------------------- 1 | use ciborium::{de::from_reader, ser::into_writer}; 2 | use freenet_stdlib::prelude::*; 3 | 4 | use freenet_scaffold::ComposableState; 5 | use freenet_stdlib::prelude::ContractError; 6 | use river_common::room_state::{ 7 | ChatRoomParametersV1, ChatRoomStateV1Delta, ChatRoomStateV1Summary, 8 | }; 9 | use river_common::ChatRoomStateV1; 10 | 11 | #[allow(dead_code)] 12 | struct Contract; 13 | 14 | #[contract] 15 | impl ContractInterface for Contract { 16 | fn validate_state( 17 | parameters: Parameters<'static>, 18 | state: State<'static>, 19 | _related: RelatedContracts<'static>, 20 | ) -> Result { 21 | let bytes = state.as_ref(); 22 | // allow empty room_state 23 | if bytes.is_empty() { 24 | return Ok(ValidateResult::Valid); 25 | } 26 | let chat_state = from_reader::(bytes) 27 | .map_err(|e| ContractError::Deser(e.to_string()))?; 28 | 29 | let parameters = from_reader::(parameters.as_ref()) 30 | .map_err(|e| ContractError::Deser(e.to_string()))?; 31 | 32 | chat_state 33 | .verify(&chat_state, ¶meters) 34 | .map(|_| ValidateResult::Valid) 35 | .map_err(|_| ContractError::InvalidState) 36 | } 37 | 38 | fn update_state( 39 | parameters: Parameters<'static>, 40 | state: State<'static>, 41 | data: Vec>, 42 | ) -> Result, freenet_stdlib::prelude::ContractError> { 43 | let parameters = from_reader::(parameters.as_ref()) 44 | .map_err(|e| ContractError::Deser(e.to_string()))?; 45 | let mut chat_state = from_reader::(state.as_ref()) 46 | .map_err(|e| ContractError::Deser(e.to_string()))?; 47 | 48 | for update in data { 49 | match update { 50 | UpdateData::State(new_state) => { 51 | let new_state = from_reader::(new_state.as_ref()) 52 | .map_err(|e| ContractError::Deser(e.to_string()))?; 53 | chat_state 54 | .merge(&chat_state.clone(), ¶meters, &new_state) 55 | .map_err(|_| ContractError::InvalidUpdate)?; 56 | } 57 | UpdateData::Delta(d) => { 58 | let delta = from_reader::(d.as_ref()) 59 | .map_err(|e| ContractError::Deser(e.to_string()))?; 60 | chat_state 61 | .apply_delta(&chat_state.clone(), ¶meters, &Some(delta)) 62 | .map_err(|_| ContractError::InvalidUpdate)?; 63 | } 64 | UpdateData::RelatedState { 65 | related_to: _, 66 | state: _, 67 | } => { 68 | // TODO: related room_state handling not needed for river 69 | } 70 | _ => unreachable!(), 71 | } 72 | } 73 | 74 | let mut updated_state = vec![]; 75 | into_writer(&chat_state, &mut updated_state) 76 | .map_err(|e| ContractError::Deser(e.to_string()))?; 77 | 78 | Ok(UpdateModification::valid(updated_state.into())) 79 | } 80 | 81 | fn summarize_state( 82 | parameters: Parameters<'static>, 83 | state: State<'static>, 84 | ) -> Result, freenet_stdlib::prelude::ContractError> { 85 | let state = state.as_ref(); 86 | if state.is_empty() { 87 | return Ok(StateSummary::from(vec![])); 88 | } 89 | let parameters = from_reader::(parameters.as_ref()) 90 | .map_err(|e| ContractError::Deser(e.to_string()))?; 91 | let state = from_reader::(state) 92 | .map_err(|e| ContractError::Deser(e.to_string()))?; 93 | let summary = state.summarize(&state, ¶meters); 94 | let mut summary_bytes = vec![]; 95 | into_writer(&summary, &mut summary_bytes) 96 | .map_err(|e| ContractError::Deser(e.to_string()))?; 97 | Ok(StateSummary::from(summary_bytes)) 98 | } 99 | 100 | fn get_state_delta( 101 | parameters: Parameters<'static>, 102 | state: State<'static>, 103 | summary: StateSummary<'static>, 104 | ) -> Result, freenet_stdlib::prelude::ContractError> { 105 | let chat_state = from_reader::(state.as_ref()) 106 | .map_err(|e| ContractError::Deser(e.to_string()))?; 107 | let parameters = from_reader::(parameters.as_ref()) 108 | .map_err(|e| ContractError::Deser(e.to_string()))?; 109 | let summary = from_reader::(summary.as_ref()) 110 | .map_err(|e| ContractError::Deser(e.to_string()))?; 111 | let delta = chat_state.delta(&chat_state, ¶meters, &summary); 112 | let mut delta_bytes = vec![]; 113 | into_writer(&delta, &mut delta_bytes).map_err(|e| ContractError::Deser(e.to_string()))?; 114 | Ok(StateDelta::from(delta_bytes)) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /contracts/room-contract/wasm32-unknown-unknown/CACHEDIR.TAG: -------------------------------------------------------------------------------- 1 | Signature: 8a477f597d28d172789f06886806bc55 2 | # This file is a cache directory tag created by cargo. 3 | # For information about cache directory tags see https://bford.info/cachedir/ 4 | -------------------------------------------------------------------------------- /contracts/web-container-contract/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "web-container-contract" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | byteorder.workspace = true 8 | ciborium.workspace = true 9 | freenet-stdlib.workspace = true 10 | river-common = { workspace = true } 11 | bs58 = { workspace = true } 12 | 13 | # Fully remove `rand_core` from `ed25519-dalek` for wasm builds 14 | ed25519-dalek = { workspace = true, default-features = false, features = ["alloc", "serde"] } 15 | 16 | [dev-dependencies] 17 | # Enable rand_core feature for tests 18 | ed25519-dalek = { workspace = true, features = ["rand_core"] } 19 | rand = { workspace = true, features = ["std_rng"] } 20 | tar = "0.4" 21 | 22 | [lib] 23 | crate-type = ["cdylib", "rlib"] 24 | 25 | 26 | [features] 27 | default = ["freenet-main-contract"] 28 | contract = ["freenet-stdlib/contract"] 29 | freenet-main-contract = [] 30 | trace = ["freenet-stdlib/trace"] 31 | 32 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 33 | rand = { workspace = true } 34 | 35 | [target.'cfg(target_arch = "wasm32")'.dependencies] 36 | getrandom = { version = "0.2.15", features = ["js"], default-features = false } 37 | 38 | -------------------------------------------------------------------------------- /contracts/web-container-contract/freenet.toml: -------------------------------------------------------------------------------- 1 | [contract] 2 | type = "webapp" 3 | lang = "rust" 4 | 5 | [webapp] 6 | lang = "rust" 7 | state-sources = { source_dirs = [ "../../target/dx/river-ui/release/web/public" ] } 8 | # metadata -------------------------------------------------------------------------------- /contracts/web-container-contract/tests/integration_tests.rs: -------------------------------------------------------------------------------- 1 | #![cfg(not(target_arch = "wasm32"))] 2 | 3 | use byteorder::{BigEndian, WriteBytesExt}; 4 | use ed25519_dalek::{Signer, SigningKey}; 5 | use freenet_stdlib::prelude::*; 6 | use rand::rngs::OsRng; 7 | use river_common::web_container::WebContainerMetadata; 8 | use tar::Builder; 9 | use web_container_contract::WebContainerContract; 10 | 11 | // Mock implementation of freenet logger for tests 12 | #[no_mangle] 13 | pub extern "C" fn __frnt__logger__info(_ptr: i32, _len: i32) {} 14 | 15 | fn create_test_webapp() -> Vec { 16 | let mut builder = Builder::new(Vec::new()); 17 | let content = b"test content"; 18 | let mut header = tar::Header::new_gnu(); 19 | header.set_size(content.len() as u64); 20 | builder 21 | .append_data( 22 | &mut header, 23 | &std::path::Path::new("index.html"), 24 | content.as_ref(), 25 | ) 26 | .unwrap(); 27 | builder.into_inner().unwrap() 28 | } 29 | 30 | #[test] 31 | fn test_tool_and_contract_compatibility() { 32 | // Generate a keypair like the tool does 33 | let signing_key = SigningKey::generate(&mut OsRng); 34 | let verifying_key = signing_key.verifying_key(); 35 | 36 | // Create a test webapp archive 37 | let webapp_bytes = create_test_webapp(); 38 | 39 | // Create message to sign (version + webapp) exactly as tool does 40 | let version: u32 = 1; 41 | let mut message = version.to_be_bytes().to_vec(); 42 | message.extend_from_slice(&webapp_bytes); 43 | 44 | // Sign the message 45 | let signature = signing_key.sign(&message); 46 | 47 | // Create metadata struct 48 | let metadata = WebContainerMetadata { version, signature }; 49 | 50 | // Serialize metadata to CBOR 51 | let mut metadata_bytes = Vec::new(); 52 | ciborium::ser::into_writer(&metadata, &mut metadata_bytes).unwrap(); 53 | 54 | // Create final state in WebApp format: 55 | // [metadata_length: u64][metadata: bytes][web_length: u64][web: bytes] 56 | let mut state = Vec::with_capacity( 57 | metadata_bytes.len() + webapp_bytes.len() + (std::mem::size_of::() * 2), 58 | ); 59 | state 60 | .write_u64::(metadata_bytes.len() as u64) 61 | .unwrap(); 62 | state.extend_from_slice(&metadata_bytes); 63 | state 64 | .write_u64::(webapp_bytes.len() as u64) 65 | .unwrap(); 66 | state.extend_from_slice(&webapp_bytes); 67 | 68 | // Verify using contract code 69 | let result = WebContainerContract::validate_state( 70 | Parameters::from(verifying_key.to_bytes().to_vec()), 71 | State::from(state), 72 | RelatedContracts::default(), 73 | ); 74 | 75 | assert!(matches!(result, Ok(ValidateResult::Valid))); 76 | } 77 | 78 | #[test] 79 | fn test_modified_webapp_fails_verification() { 80 | // Generate a keypair 81 | let signing_key = SigningKey::generate(&mut OsRng); 82 | let verifying_key = signing_key.verifying_key(); 83 | 84 | // Create and sign original webapp 85 | let webapp_bytes = create_test_webapp(); 86 | let version: u32 = 1; 87 | let mut message = version.to_be_bytes().to_vec(); 88 | message.extend_from_slice(&webapp_bytes); 89 | let signature = signing_key.sign(&message); 90 | 91 | let metadata = WebContainerMetadata { version, signature }; 92 | 93 | let mut metadata_bytes = Vec::new(); 94 | ciborium::ser::into_writer(&metadata, &mut metadata_bytes).unwrap(); 95 | 96 | // Create state but with modified webapp content 97 | let mut modified_webapp = webapp_bytes.clone(); 98 | modified_webapp[0] ^= 1; // Flip one bit 99 | 100 | let mut state = Vec::with_capacity( 101 | metadata_bytes.len() + modified_webapp.len() + (std::mem::size_of::() * 2), 102 | ); 103 | state 104 | .write_u64::(metadata_bytes.len() as u64) 105 | .unwrap(); 106 | state.extend_from_slice(&metadata_bytes); 107 | state 108 | .write_u64::(modified_webapp.len() as u64) 109 | .unwrap(); 110 | state.extend_from_slice(&modified_webapp); 111 | 112 | // This should fail verification 113 | let result = WebContainerContract::validate_state( 114 | Parameters::from(verifying_key.to_bytes().to_vec()), 115 | State::from(state), 116 | RelatedContracts::default(), 117 | ); 118 | 119 | assert!(matches!(result, Err(ContractError::Other(_)))); 120 | } 121 | -------------------------------------------------------------------------------- /contracts/web-container-contract/web-container-tool/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "web-container-tool" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | river-common.workspace = true 8 | ed25519-dalek = { workspace = true, features = ["rand_core"] } 9 | toml = "0.8" 10 | dirs = "6.0.0" 11 | rand = "0.8" 12 | clap = { version = "4.4", features = ["derive"] } 13 | ciborium.workspace = true 14 | bs58.workspace = true 15 | tracing = "0.1.41" -------------------------------------------------------------------------------- /contracts/web-container-contract/web-container-tool/src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand}; 2 | use ed25519_dalek::{Signer, SigningKey}; 3 | use river_common::crypto_values::CryptoValue; 4 | use river_common::web_container::WebContainerMetadata; 5 | use std::fs; 6 | use std::io::Write; 7 | use std::path::PathBuf; 8 | 9 | #[derive(Parser)] 10 | #[command(name = "web-container-tool")] 11 | #[command(about = "Web container key management tool")] 12 | struct Cli { 13 | #[command(subcommand)] 14 | command: Commands, 15 | } 16 | 17 | #[derive(Subcommand)] 18 | enum Commands { 19 | /// Generate a new keypair and save to config 20 | Generate, 21 | /// Sign a compressed webapp file 22 | Sign { 23 | /// Input compressed webapp file (e.g. webapp.tar.xz) 24 | #[arg(long, short)] 25 | input: String, 26 | /// Output file for metadata 27 | #[arg(long, short)] 28 | output: String, 29 | /// Output file for contract parameters 30 | #[arg(long)] 31 | parameters: String, 32 | /// Version number for the webapp 33 | #[arg(long, short)] 34 | version: u32, 35 | }, 36 | } 37 | 38 | fn generate_keys() -> Result<(), Box> { 39 | // Generate keys 40 | let signing_key = SigningKey::generate(&mut rand::rngs::OsRng); 41 | let verifying_key = signing_key.verifying_key(); 42 | let signing_key = CryptoValue::SigningKey(signing_key).to_encoded_string(); 43 | let verifying_key = CryptoValue::VerifyingKey(verifying_key).to_encoded_string(); 44 | 45 | // Create config structure 46 | let config = toml::toml! { 47 | [keys] 48 | signing_key = signing_key 49 | verifying_key = verifying_key 50 | }; 51 | 52 | // Get config directory 53 | let mut config_dir = dirs::config_dir().ok_or("Could not find config directory")?; 54 | config_dir.push("river"); 55 | 56 | // Create directory if it doesn't exist 57 | fs::create_dir_all(&config_dir)?; 58 | 59 | // Create config file path 60 | let mut config_path = config_dir; 61 | config_path.push("web-container-keys.toml"); 62 | 63 | // Write config file 64 | fs::write(&config_path, toml::to_string(&config)?)?; 65 | println!("Keys written to: {}", config_path.display()); 66 | 67 | Ok(()) 68 | } 69 | 70 | fn get_config_path() -> Result> { 71 | let mut config_dir = dirs::config_dir().ok_or("Could not find config directory")?; 72 | config_dir.push("river"); 73 | config_dir.push("web-container-keys.toml"); 74 | Ok(config_dir) 75 | } 76 | 77 | fn read_signing_key() -> Result> { 78 | let config_path = get_config_path()?; 79 | let config_str = fs::read_to_string(&config_path)?; 80 | tracing::info!("Read config from: {}", config_path.display()); 81 | 82 | let config: toml::Table = toml::from_str(&config_str)?; 83 | tracing::info!("Parsed TOML config"); 84 | 85 | let signing_key_str = config["keys"]["signing_key"] 86 | .as_str() 87 | .ok_or("Could not find signing key in config")?; 88 | tracing::info!("Found signing key string: {}", signing_key_str); 89 | 90 | // Remove the "river:v1:sk:" prefix 91 | let key_data = signing_key_str 92 | .strip_prefix("river:v1:sk:") 93 | .ok_or("Signing key must start with 'river:v1:sk:'")?; 94 | tracing::info!("Stripped prefix, parsing key data: {}", key_data); 95 | 96 | tracing::info!("Attempting to parse key data as CryptoValue: {}", key_data); 97 | let signing_key = key_data 98 | .parse::() 99 | .map_err(|e| format!("Failed to parse signing key data: {}", e))?; 100 | tracing::info!("Successfully parsed as CryptoValue: {:?}", signing_key); 101 | 102 | match signing_key { 103 | CryptoValue::SigningKey(sk) => { 104 | tracing::info!("Successfully extracted SigningKey"); 105 | Ok(sk) 106 | } 107 | other => Err(format!("Expected SigningKey, got {:?}", other).into()), 108 | } 109 | } 110 | 111 | fn sign_webapp( 112 | input: String, 113 | output: String, 114 | parameters: String, 115 | version: u32, 116 | ) -> Result<(), Box> { 117 | // Read the signing key 118 | let signing_key = match read_signing_key() { 119 | Ok(key) => { 120 | tracing::info!("Read signing key successfully"); 121 | key 122 | } 123 | Err(e) => return Err(format!("Failed to read signing key: {}", e).into()), 124 | }; 125 | 126 | // Read the compressed webapp 127 | let webapp_bytes = match fs::read(&input) { 128 | Ok(bytes) => { 129 | tracing::info!("Read {} bytes from webapp file", bytes.len()); 130 | bytes 131 | } 132 | Err(e) => return Err(format!("Failed to read webapp file '{}': {}", input, e).into()), 133 | }; 134 | 135 | // Create message to sign (version + webapp) 136 | let mut message = Vec::new(); 137 | message.extend_from_slice(&version.to_be_bytes()); 138 | message.extend_from_slice(&webapp_bytes); 139 | 140 | tracing::info!( 141 | "Created message to sign: {} bytes total ({} bytes version + {} bytes webapp)", 142 | message.len(), 143 | std::mem::size_of::(), 144 | webapp_bytes.len() 145 | ); 146 | tracing::debug!("Version bytes (hex): {:02x?}", &version.to_be_bytes()); 147 | tracing::debug!( 148 | "First 100 webapp bytes (hex): {:02x?}", 149 | &webapp_bytes[..100.min(webapp_bytes.len())] 150 | ); 151 | tracing::debug!( 152 | "First 100 message bytes (hex): {:02x?}", 153 | &message[..100.min(message.len())] 154 | ); 155 | 156 | // Output debug info 157 | let verifying_key = signing_key.verifying_key(); 158 | tracing::debug!( 159 | "Verifying key (base58): {}", 160 | bs58::encode(verifying_key.to_bytes()).into_string() 161 | ); 162 | tracing::debug!("Verifying key (hex): {:02x?}", verifying_key.to_bytes()); 163 | tracing::info!("Message length: {} bytes", message.len()); 164 | if message.len() > 20 { 165 | tracing::debug!( 166 | "Message first 10 bytes (base58): {}", 167 | bs58::encode(&message[..10]).into_string() 168 | ); 169 | tracing::debug!( 170 | "Message last 10 bytes (base58): {}", 171 | bs58::encode(&message[message.len() - 10..]).into_string() 172 | ); 173 | } else { 174 | tracing::debug!("Message (base58): {}", bs58::encode(&message).into_string()); 175 | } 176 | 177 | // Sign the message 178 | let signature = signing_key.sign(&message); 179 | tracing::info!( 180 | "Generated signature (base58): {}", 181 | bs58::encode(signature.to_bytes()).into_string() 182 | ); 183 | tracing::info!("Signature length: {} bytes", signature.to_bytes().len()); 184 | 185 | // Create metadata 186 | let metadata = WebContainerMetadata { version, signature }; 187 | tracing::info!("Created metadata struct with version {}", version); 188 | 189 | // Serialize metadata to check exact bytes 190 | let mut metadata_bytes = Vec::new(); 191 | ciborium::ser::into_writer(&metadata, &mut metadata_bytes) 192 | .map_err(|e| format!("Failed to serialize metadata: {}", e))?; 193 | tracing::debug!("Serialized metadata size: {} bytes", metadata_bytes.len()); 194 | tracing::debug!( 195 | "First 32 metadata bytes (hex): {:02x?}", 196 | &metadata_bytes[..32.min(metadata_bytes.len())] 197 | ); 198 | 199 | // Create output file 200 | let mut output_file = match fs::File::create(&output) { 201 | Ok(file) => { 202 | tracing::info!("Created output file: {}", output); 203 | file 204 | } 205 | Err(e) => return Err(format!("Failed to create output file '{}': {}", output, e).into()), 206 | }; 207 | 208 | // Serialize and write metadata as CBOR 209 | let mut metadata_bytes = Vec::new(); 210 | ciborium::ser::into_writer(&metadata, &mut metadata_bytes) 211 | .map_err(|e| format!("Failed to serialize metadata: {}", e))?; 212 | 213 | output_file 214 | .write_all(&metadata_bytes) 215 | .map_err(|e| format!("Failed to write metadata: {}", e))?; 216 | if metadata_bytes.len() > 64 { 217 | tracing::debug!( 218 | "First 32 metadata bytes (hex): {:02x?}", 219 | &metadata_bytes[..32] 220 | ); 221 | tracing::debug!( 222 | "Last 32 metadata bytes (hex): {:02x?}", 223 | &metadata_bytes[metadata_bytes.len() - 32..] 224 | ); 225 | } else { 226 | tracing::debug!("Metadata bytes (hex): {:02x?}", &metadata_bytes); 227 | } 228 | 229 | println!("Metadata written to: {}", output); 230 | 231 | // Write parameters file containing verifying key bytes 232 | let verifying_key = signing_key.verifying_key(); 233 | fs::write(¶meters, verifying_key.to_bytes()) 234 | .map_err(|e| format!("Failed to write parameters to '{}': {}", parameters, e))?; 235 | println!("Parameters written to: {}", parameters); 236 | 237 | Ok(()) 238 | } 239 | 240 | fn main() -> Result<(), Box> { 241 | let cli = Cli::parse(); 242 | 243 | match cli.command { 244 | Commands::Generate => generate_keys(), 245 | Commands::Sign { 246 | input, 247 | output, 248 | parameters, 249 | version, 250 | } => sign_webapp(input, output, parameters, version), 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /delegates/chat-delegate/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chat-delegate" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [features] 7 | default = ["freenet-main-delegate"] 8 | freenet-main-delegate = [] 9 | 10 | [dependencies] 11 | ciborium.workspace = true 12 | freenet-stdlib.workspace = true 13 | bs58 = { workspace = true } 14 | serde = { workspace = true, features = ["derive"] } 15 | river-common.workspace = true 16 | 17 | [lib] 18 | crate-type = ["cdylib", "rlib"] 19 | 20 | -------------------------------------------------------------------------------- /delegates/chat-delegate/README.md: -------------------------------------------------------------------------------- 1 | # ChatDelegate Overview 2 | 3 | The ChatDelegate is a key-value storage system for chat applications in Freenet. It provides origin-based data partitioning similar to a web browser's localStorage API, ensuring data isolation between different applications. This document explains how the delegate handles different types of messages and the overall flow of operations. 4 | 5 | ## Overview 6 | 7 | The ChatDelegate provides four main operations: 8 | 1. **Store** - Save data with a specific key 9 | 2. **Get** - Retrieve data for a key 10 | 3. **Delete** - Remove data for a key 11 | 4. **List** - Get all available keys 12 | 13 | Each operation involves a multi-step process due to the asynchronous nature of the delegate system. 14 | 15 | ## Origin-Based Data Partitioning 16 | 17 | A key security feature of the ChatDelegate is that all data is partitioned by "origin contract" - the contract key from which the user interface was downloaded. This works similarly to a web browser's localStorage, where data is partitioned by the hostname/port of the website: 18 | 19 | 1. Each piece of data is stored with a composite key that includes the origin contract ID 20 | 2. Applications can only access data that was stored by the same origin 21 | 3. This prevents different applications from accessing each other's data 22 | 4. The partitioning happens automatically and is transparent to the application 23 | 24 | This design ensures that even if multiple chat applications use the same delegate, their data remains isolated and secure from each other. 25 | 26 | ## Message Flow Architecture 27 | 28 | The delegate uses a state machine pattern where: 29 | 1. Initial application messages trigger operations 30 | 2. State is stored in a context object between operations 31 | 3. Responses from the storage system trigger follow-up actions 32 | 33 | ## Processing Inbound Messages 34 | 35 | The entry point is the `process` method in `lib.rs`, which handles four types of messages: 36 | 37 | ```rust 38 | match message { 39 | InboundDelegateMsg::ApplicationMessage(app_msg) => { 40 | // Handle client requests (Store/Get/Delete/List) 41 | handle_application_message(app_msg, &origin) 42 | } 43 | InboundDelegateMsg::GetSecretResponse(response) => { 44 | // Handle responses from the storage system 45 | handle_get_secret_response(response) 46 | } 47 | InboundDelegateMsg::UserResponse(_) => { 48 | // Not used in this delegate 49 | Ok(vec![]) 50 | } 51 | InboundDelegateMsg::GetSecretRequest(_) => { 52 | // Not handled directly 53 | Err(DelegateError::Other("unexpected message type: get secret request".into())) 54 | } 55 | } 56 | ``` 57 | 58 | ## Key Concepts 59 | 60 | Before diving into specific flows, let's understand some key concepts: 61 | 62 | 1. **Origin**: The contract ID that identifies the application 63 | 2. **ChatDelegateKey**: A wrapper around a byte vector that represents a key 64 | 3. **KeyIndex**: A list of all keys for a specific origin 65 | 4. **PendingOperation**: State stored in the context to track ongoing operations 66 | 67 | ## 1. Store Operation Flow 68 | 69 | When a client wants to store data: 70 | 71 | 1. **Client sends a StoreRequest**: 72 | ```rust 73 | ChatDelegateRequestMsg::StoreRequest { key, value } 74 | ``` 75 | 76 | 2. **Delegate processes the request** (`handle_store_request`): 77 | - Creates a unique storage key by combining the origin and client key 78 | - Stores the operation in context for later processing 79 | - Immediately sends back a success response to the client 80 | - Sends a request to store the actual value 81 | - Requests the current key index to update it 82 | 83 | 3. **Delegate receives the key index** (`handle_key_index_response`): 84 | - Adds the new key to the index if it doesn't exist 85 | - Updates the index in storage 86 | - The operation is complete 87 | 88 | ## 2. Get Operation Flow 89 | 90 | When a client wants to retrieve data: 91 | 92 | 1. **Client sends a GetRequest**: 93 | ```rust 94 | ChatDelegateRequestMsg::GetRequest { key } 95 | ``` 96 | 97 | 2. **Delegate processes the request** (`handle_get_request`): 98 | - Creates the unique storage key 99 | - Stores the operation in context 100 | - Sends a request to get the value from storage 101 | 102 | 3. **Delegate receives the value** (`handle_regular_get_response`): 103 | - Retrieves the pending operation from context 104 | - Sends the value back to the client 105 | - The operation is complete 106 | 107 | ## 3. Delete Operation Flow 108 | 109 | When a client wants to delete data: 110 | 111 | 1. **Client sends a DeleteRequest**: 112 | ```rust 113 | ChatDelegateRequestMsg::DeleteRequest { key } 114 | ``` 115 | 116 | 2. **Delegate processes the request** (`handle_delete_request`): 117 | - Creates the unique storage key 118 | - Stores the operation in context 119 | - Immediately sends back a success response to the client 120 | - Sends a request to delete the value (by setting it to None) 121 | - Requests the current key index to update it 122 | 123 | 3. **Delegate receives the key index** (`handle_key_index_response`): 124 | - Removes the key from the index 125 | - Updates the index in storage 126 | - The operation is complete 127 | 128 | ## 4. List Operation Flow 129 | 130 | When a client wants to list all keys: 131 | 132 | 1. **Client sends a ListRequest**: 133 | ```rust 134 | ChatDelegateRequestMsg::ListRequest 135 | ``` 136 | 137 | 2. **Delegate processes the request** (`handle_list_request`): 138 | - Stores the operation in context 139 | - Requests the current key index 140 | 141 | 3. **Delegate receives the key index** (`handle_key_index_response`): 142 | - Sends the list of keys back to the client 143 | - The operation is complete 144 | 145 | ## Key Management 146 | 147 | A critical aspect of the delegate is how it manages keys: 148 | 149 | 1. **Origin-Based Key Namespacing**: Each key is prefixed with the origin contract ID to enforce data partitioning: 150 | ```rust 151 | pub(crate) fn create_origin_key(origin: &Origin, key: &ChatDelegateKey) -> SecretsId { 152 | SecretsId::new( 153 | format!("{}{}{}", origin.to_b58(), ORIGIN_KEY_SEPARATOR, 154 | String::from_utf8_lossy(key.as_bytes()).to_string()).into_bytes() 155 | ) 156 | } 157 | ``` 158 | 159 | This ensures that: 160 | - Data from different origins is completely isolated 161 | - Applications can only access their own data 162 | - Key collisions between different applications are impossible 163 | 164 | 2. **Origin-Specific Key Index**: Each origin has its own separate key index: 165 | ```rust 166 | pub(crate) fn create_index_key(origin: &Origin) -> SecretsId { 167 | SecretsId::new(format!( 168 | "{}{}{}", 169 | origin.to_b58(), 170 | ORIGIN_KEY_SEPARATOR, 171 | KEY_INDEX_SUFFIX 172 | ).into_bytes()) 173 | } 174 | ``` 175 | 176 | This means: 177 | - Each application has its own isolated list of keys 178 | - The ListRequest operation only returns keys for the calling application's origin 179 | - Applications cannot enumerate keys from other origins 180 | 181 | ## Context Management 182 | 183 | The delegate uses a context object to maintain state between operations: 184 | 185 | ```rust 186 | pub(super) struct ChatDelegateContext { 187 | pub(super) pending_ops: HashMap, 188 | } 189 | ``` 190 | 191 | This context is serialized and passed along with requests, then deserialized when responses are received. 192 | 193 | ## Tying It All Together 194 | 195 | The overall flow for any operation follows this pattern: 196 | 197 | 1. **Client Request**: Application sends a request to the delegate 198 | 2. **Initial Processing**: Delegate creates necessary storage keys and stores state in context 199 | 3. **Storage Operations**: Delegate interacts with the storage system 200 | 4. **Response Handling**: Delegate processes storage responses and updates state 201 | 5. **Client Response**: Delegate sends final response back to the application 202 | 203 | This asynchronous, multi-step approach allows the delegate to maintain consistency while providing a simple interface to client applications. 204 | 205 | ## Example: Complete Store Flow 206 | 207 | Let's trace a complete store operation with origin partitioning: 208 | 209 | 1. Client sends `StoreRequest { key: "user123", value: [profile data] }` 210 | 2. Delegate: 211 | - Identifies the origin contract ID (e.g., `abc123`) from which the request came 212 | - Creates storage key: `abc123:user123` (prefixing the client key with origin) 213 | - Creates index key: `abc123::key_index` (origin-specific index) 214 | - Stores pending operation in context 215 | - Sends success response to client 216 | - Sends request to store value at `abc123:user123` 217 | - Sends request to get current index at `abc123::key_index` 218 | 3. Delegate receives index (or empty if first key) 219 | 4. Delegate: 220 | - Adds "user123" to index if not present 221 | - Updates index in storage 222 | - Operation complete 223 | 224 | If a different application with origin `xyz789` tries to access this data: 225 | - It would use key `xyz789:user123` which is different from `abc123:user123` 226 | - It would not find the data stored by the first application 227 | - It would have its own separate key index at `xyz789::key_index` 228 | 229 | This architecture ensures both data consistency and security through origin-based isolation, while providing a responsive experience for client applications. 230 | -------------------------------------------------------------------------------- /delegates/chat-delegate/src/context.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// Context for the chat delegate, storing pending operations. 4 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 5 | pub(super) struct ChatDelegateContext { 6 | /// Map of secret IDs to pending operations 7 | pub(super) pending_ops: HashMap, 8 | } 9 | 10 | /// Structure to store the index of keys for an app 11 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 12 | pub(super) struct KeyIndex { 13 | pub(super) keys: Vec, 14 | } 15 | 16 | impl TryFrom for ChatDelegateContext { 17 | type Error = DelegateError; 18 | 19 | fn try_from(value: DelegateContext) -> Result { 20 | if value == DelegateContext::default() { 21 | return Ok(Self::default()); 22 | } 23 | ciborium::from_reader(value.as_ref()) 24 | .map_err(|err| DelegateError::Deser(format!("Failed to deserialize context: {err}"))) 25 | } 26 | } 27 | 28 | impl TryFrom<&ChatDelegateContext> for DelegateContext { 29 | type Error = DelegateError; 30 | 31 | fn try_from(value: &ChatDelegateContext) -> Result { 32 | let mut buffer = Vec::new(); 33 | ciborium::ser::into_writer(value, &mut buffer) 34 | .map_err(|err| DelegateError::Deser(format!("Failed to serialize context: {err}")))?; 35 | Ok(DelegateContext::new(buffer)) 36 | } 37 | } 38 | 39 | impl TryFrom<&mut ChatDelegateContext> for DelegateContext { 40 | type Error = DelegateError; 41 | 42 | fn try_from(value: &mut ChatDelegateContext) -> Result { 43 | // Delegate to the immutable reference implementation 44 | Self::try_from(&*value) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /delegates/chat-delegate/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod context; 2 | mod handlers; 3 | mod models; 4 | mod utils; 5 | 6 | use context::*; 7 | use freenet_stdlib::prelude::{ 8 | delegate, ApplicationMessage, DelegateContext, DelegateError, DelegateInterface, 9 | GetSecretRequest, InboundDelegateMsg, OutboundDelegateMsg, Parameters, SecretsId, 10 | SetSecretRequest, 11 | }; 12 | use handlers::*; 13 | use models::*; 14 | use utils::*; 15 | 16 | // Custom logging module to handle different environments 17 | mod logging; 18 | 19 | use river_common::chat_delegate::*; 20 | use serde::ser::SerializeTuple; 21 | use serde::{Deserialize, Serialize}; 22 | use std::collections::HashMap; 23 | 24 | /// Chat delegate for storing and retrieving data in the Freenet secret storage. 25 | /// 26 | /// This delegate provides a key-value store interface for chat applications, 27 | /// maintaining an index of keys for each application and handling storage, 28 | /// retrieval, deletion, and listing operations. 29 | pub struct ChatDelegate; 30 | 31 | #[delegate] 32 | impl DelegateInterface for ChatDelegate { 33 | fn process( 34 | _parameters: Parameters<'static>, 35 | attested: Option<&'static [u8]>, 36 | message: InboundDelegateMsg, 37 | ) -> Result, DelegateError> { 38 | let message_type = match message { 39 | InboundDelegateMsg::ApplicationMessage(_) => "application message", 40 | InboundDelegateMsg::GetSecretResponse(_) => "get secret response", 41 | InboundDelegateMsg::UserResponse(_) => "user response", 42 | InboundDelegateMsg::GetSecretRequest(_) => "get secret request", 43 | }; 44 | 45 | logging::info(&format!("Delegate received message of type {message_type}")); 46 | 47 | // Verify that attested is provided - this is the authenticated origin 48 | let origin: Origin = match attested { 49 | Some(origin) => Origin(origin.to_vec()), 50 | None => { 51 | logging::info("Missing attested origin"); 52 | return Err(DelegateError::Other(format!( 53 | "missing attested origin for message type: {:?}", 54 | message_type 55 | ))); 56 | } 57 | }; 58 | 59 | let result = match message { 60 | InboundDelegateMsg::ApplicationMessage(app_msg) => { 61 | if app_msg.processed { 62 | logging::info("Received already processed message"); 63 | Err(DelegateError::Other( 64 | "cannot process an already processed message".into(), 65 | )) 66 | } else { 67 | handle_application_message(app_msg, &origin) 68 | } 69 | } 70 | 71 | InboundDelegateMsg::GetSecretResponse(response) => handle_get_secret_response(response), 72 | 73 | InboundDelegateMsg::UserResponse(_) => { 74 | logging::info("Received unexpected UserResponse"); 75 | Err(DelegateError::Other( 76 | "unexpected message type: UserResponse".into(), 77 | )) 78 | } 79 | 80 | InboundDelegateMsg::GetSecretRequest(_) => { 81 | // We don't handle direct get secret requests 82 | logging::info("Received unexpected GetSecretRequest"); 83 | Err(DelegateError::Other( 84 | "unexpected message type: GetSecretRequest".into(), 85 | )) 86 | } 87 | }; 88 | 89 | match &result { 90 | Ok(msgs) => logging::info(&format!("Process returning {} messages", msgs.len())), 91 | Err(e) => logging::info(&format!("Process returning error: {e}")), 92 | } 93 | 94 | result 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /delegates/chat-delegate/src/logging.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_arch = "wasm32")] 2 | pub fn info(msg: &str) { 3 | freenet_stdlib::log::info(msg); 4 | } 5 | 6 | #[cfg(not(target_arch = "wasm32"))] 7 | pub fn info(msg: &str) { 8 | println!("[INFO] {}", msg); 9 | } 10 | -------------------------------------------------------------------------------- /delegates/chat-delegate/src/models.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use freenet_stdlib::prelude::ContractInstanceId; 3 | 4 | // Constants 5 | pub(crate) const KEY_INDEX_SUFFIX: &str = "::key_index"; 6 | pub(crate) const ORIGIN_KEY_SEPARATOR: &str = ":"; 7 | 8 | /// Different types of pending operations 9 | #[derive(Debug, Clone)] 10 | pub(crate) enum PendingOperation { 11 | /// Regular get operation for a specific key 12 | Get { 13 | origin: Origin, 14 | client_key: ChatDelegateKey, 15 | app: ContractInstanceId, 16 | }, 17 | /// Store operation that needs to update the index 18 | Store { 19 | origin: Origin, 20 | client_key: ChatDelegateKey, 21 | }, 22 | /// Delete operation that needs to update the index 23 | Delete { 24 | origin: Origin, 25 | client_key: ChatDelegateKey, 26 | }, 27 | /// List operation to retrieve all keys 28 | List { 29 | origin: Origin, 30 | app: ContractInstanceId, 31 | }, 32 | } 33 | 34 | impl PendingOperation { 35 | pub(crate) fn is_delete_operation(&self) -> bool { 36 | matches!(self, Self::Delete { .. }) 37 | } 38 | } 39 | 40 | /// Parameters for the chat delegate. 41 | /// Currently empty, but could be extended with configuration options. 42 | #[derive(Debug, Clone, Serialize, Deserialize)] 43 | pub(crate) struct ChatDelegateParameters; 44 | 45 | impl TryFrom> for ChatDelegateParameters { 46 | type Error = DelegateError; 47 | 48 | fn try_from(_params: Parameters<'_>) -> Result { 49 | // Currently no parameters are used, but this could be extended 50 | Ok(Self {}) 51 | } 52 | } 53 | 54 | /// A wrapper around SecretsId that implements Hash and Eq 55 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] 56 | pub(crate) struct SecretIdKey(pub(crate) String); 57 | 58 | impl From<&SecretsId> for SecretIdKey { 59 | fn from(id: &SecretsId) -> Self { 60 | // Convert the SecretsId to a string representation for hashing 61 | Self(String::from_utf8_lossy(id.key()).to_string()) 62 | } 63 | } 64 | 65 | // Add Serialize/Deserialize for PendingOperation 66 | impl Serialize for PendingOperation { 67 | fn serialize(&self, serializer: S) -> Result 68 | where 69 | S: serde::Serializer, 70 | { 71 | match self { 72 | Self::Get { 73 | origin, 74 | client_key, 75 | app, 76 | } => { 77 | let mut seq = serializer.serialize_tuple(4)?; // Increased size 78 | seq.serialize_element(&0u8)?; // Type tag for Get 79 | seq.serialize_element(origin)?; 80 | seq.serialize_element(client_key)?; 81 | seq.serialize_element(app)?; // Serialize app 82 | seq.end() 83 | } 84 | Self::Store { origin, client_key } => { 85 | let mut seq = serializer.serialize_tuple(3)?; 86 | seq.serialize_element(&1u8)?; // Type tag for Store 87 | seq.serialize_element(origin)?; 88 | seq.serialize_element(client_key)?; 89 | seq.end() 90 | } 91 | Self::Delete { origin, client_key } => { 92 | let mut seq = serializer.serialize_tuple(3)?; 93 | seq.serialize_element(&2u8)?; // Type tag for Delete 94 | seq.serialize_element(origin)?; 95 | seq.serialize_element(client_key)?; 96 | seq.end() 97 | } 98 | Self::List { origin, app } => { 99 | let mut seq = serializer.serialize_tuple(3)?; // Increased size 100 | seq.serialize_element(&3u8)?; // Type tag for List 101 | seq.serialize_element(origin)?; 102 | seq.serialize_element(app)?; // Serialize app 103 | seq.end() 104 | } 105 | } 106 | } 107 | } 108 | 109 | impl<'de> Deserialize<'de> for PendingOperation { 110 | fn deserialize(deserializer: D) -> Result 111 | where 112 | D: serde::Deserializer<'de>, 113 | { 114 | use serde::de::{Error, SeqAccess, Visitor}; 115 | use std::fmt; 116 | 117 | struct PendingOpVisitor; 118 | 119 | impl<'de> Visitor<'de> for PendingOpVisitor { 120 | type Value = PendingOperation; 121 | 122 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 123 | formatter.write_str("a tuple with a type tag and operation data") 124 | } 125 | 126 | fn visit_seq(self, mut seq: A) -> Result 127 | where 128 | A: SeqAccess<'de>, 129 | { 130 | let tag: u8 = seq 131 | .next_element()? 132 | .ok_or_else(|| Error::invalid_length(0, &self))?; 133 | 134 | match tag { 135 | 0 => { 136 | // Get 137 | let origin: Origin = seq 138 | .next_element()? 139 | .ok_or_else(|| Error::invalid_length(1, &self))?; 140 | let client_key: ChatDelegateKey = seq 141 | .next_element()? 142 | .ok_or_else(|| Error::invalid_length(2, &self))?; 143 | let app: ContractInstanceId = seq 144 | .next_element()? 145 | .ok_or_else(|| Error::invalid_length(3, &self))?; // Deserialize app 146 | Ok(PendingOperation::Get { 147 | origin, 148 | client_key, 149 | app, 150 | }) 151 | } 152 | 1 => { 153 | // Store 154 | let origin: Origin = seq 155 | .next_element()? 156 | .ok_or_else(|| Error::invalid_length(1, &self))?; 157 | let client_key: ChatDelegateKey = seq 158 | .next_element()? 159 | .ok_or_else(|| Error::invalid_length(2, &self))?; 160 | Ok(PendingOperation::Store { origin, client_key }) 161 | } 162 | 2 => { 163 | // Delete 164 | let origin: Origin = seq 165 | .next_element()? 166 | .ok_or_else(|| Error::invalid_length(1, &self))?; 167 | let client_key: ChatDelegateKey = seq 168 | .next_element()? 169 | .ok_or_else(|| Error::invalid_length(2, &self))?; 170 | Ok(PendingOperation::Delete { origin, client_key }) 171 | } 172 | 3 => { 173 | // List 174 | let origin: Origin = seq 175 | .next_element()? 176 | .ok_or_else(|| Error::invalid_length(1, &self))?; 177 | let app: ContractInstanceId = seq 178 | .next_element()? 179 | .ok_or_else(|| Error::invalid_length(2, &self))?; // Deserialize app 180 | Ok(PendingOperation::List { origin, app }) 181 | } 182 | _ => Err(Error::custom(format!( 183 | "Unknown operation type tag: {}", 184 | tag 185 | ))), 186 | } 187 | } 188 | } 189 | // Adjust expected length based on the variant (max length is now 4 for Get) 190 | // Note: Deserializing tuples with variable lengths like this can be tricky. 191 | // A struct-based approach might be more robust if complexity increases. 192 | // For now, deserialize_tuple with a max length should work if variants are handled correctly. 193 | deserializer.deserialize_tuple(4, PendingOpVisitor) 194 | } 195 | } 196 | 197 | /// Origin contract ID 198 | #[derive(Debug, Clone, Serialize, Deserialize)] 199 | pub(crate) struct Origin(pub(crate) Vec); 200 | 201 | impl Origin { 202 | pub(crate) fn to_b58(&self) -> String { 203 | bs58::encode(&self.0).into_string() 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /delegates/chat-delegate/src/utils.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use freenet_stdlib::prelude::ContractInstanceId; 3 | 4 | /// Helper function to create a unique app key 5 | pub(crate) fn create_origin_key(origin: &Origin, key: &ChatDelegateKey) -> SecretsId { 6 | SecretsId::new( 7 | format!( 8 | "{}{}{}", 9 | origin.to_b58(), 10 | ORIGIN_KEY_SEPARATOR, 11 | String::from_utf8_lossy(key.as_bytes()).to_string() 12 | ) 13 | .into_bytes(), 14 | ) 15 | } 16 | 17 | /// Helper function to create an index key 18 | pub(crate) fn create_index_key(origin: &Origin) -> SecretsId { 19 | SecretsId::new( 20 | format!( 21 | "{}{}{}", 22 | origin.to_b58(), 23 | ORIGIN_KEY_SEPARATOR, 24 | KEY_INDEX_SUFFIX 25 | ) 26 | .into_bytes(), 27 | ) 28 | } 29 | 30 | /// Helper function to create a get request 31 | pub(crate) fn create_get_request( 32 | secret_id: SecretsId, 33 | context: &DelegateContext, 34 | ) -> Result { 35 | let get_secret = OutboundDelegateMsg::GetSecretRequest(GetSecretRequest { 36 | key: secret_id, 37 | context: context.clone(), 38 | processed: false, 39 | }); 40 | 41 | Ok(get_secret) 42 | } 43 | 44 | /// Helper function to create a get index request 45 | pub(crate) fn create_get_index_request( 46 | index_secret_id: SecretsId, 47 | context: &DelegateContext, 48 | ) -> Result { 49 | let get_index = OutboundDelegateMsg::GetSecretRequest(GetSecretRequest { 50 | key: index_secret_id, 51 | context: context.clone(), 52 | processed: false, 53 | }); 54 | 55 | Ok(get_index) 56 | } 57 | 58 | /// Helper function to create an app response 59 | pub(crate) fn create_app_response( 60 | response: &T, 61 | context: &DelegateContext, 62 | app: ContractInstanceId, 63 | ) -> Result { 64 | // Serialize response 65 | let mut response_bytes = Vec::new(); 66 | ciborium::ser::into_writer(response, &mut response_bytes) 67 | .map_err(|e| DelegateError::Deser(format!("Failed to serialize response: {e}")))?; 68 | 69 | logging::info(&format!( 70 | "Creating app response with {} bytes", 71 | response_bytes.len() 72 | )); 73 | 74 | // Create response message 75 | let app_msg = ApplicationMessage::new(app, response_bytes) 76 | .with_context(context.clone()) 77 | .processed(false); // 78 | 79 | Ok(OutboundDelegateMsg::ApplicationMessage(app_msg)) 80 | } 81 | -------------------------------------------------------------------------------- /published-contract/contract-id.txt: -------------------------------------------------------------------------------- 1 | BcfxyjCH4snaknrBoCiqhYc9UFvmiJvhsp5d4L5DuvRa 2 | -------------------------------------------------------------------------------- /published-contract/web_container_contract.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/published-contract/web_container_contract.wasm -------------------------------------------------------------------------------- /published-contract/webapp.parameters: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/published-contract/webapp.parameters -------------------------------------------------------------------------------- /screenshot-20241009.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/screenshot-20241009.png -------------------------------------------------------------------------------- /tests.rs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/tests.rs -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | /dist/ 5 | /static/ 6 | /.dioxus/ 7 | 8 | # this file will generate by tailwind: 9 | /assets/tailwind.css 10 | 11 | # These are backup files generated by rustfmt 12 | **/*.rs.bk 13 | 14 | public/ 15 | -------------------------------------------------------------------------------- /ui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "river-ui" 3 | version.workspace = true 4 | edition.workspace = true 5 | authors = ["Ian Clarke "] 6 | 7 | [features] 8 | default = [] # Add a default feature set if needed, often empty 9 | delegate = [] # Define the delegate feature 10 | example-data = [] 11 | no-sync = [] 12 | 13 | [dependencies] 14 | bs58 = "0.5.0" 15 | serde.workspace = true 16 | # Cryptography 17 | curve25519-dalek.workspace = true 18 | x25519-dalek.workspace = true 19 | # We can use rand_core in the UI crate because the wasm runs in a browser 20 | ed25519-dalek = { workspace = true, features = ["rand_core"] } 21 | sha2.workspace = true 22 | aes-gcm.workspace = true 23 | 24 | # Randomness 25 | rand.workspace = true 26 | getrandom = { version = "0.2.15", features = ["js", "wasm-bindgen", "js-sys"], default-features = false } 27 | 28 | # UI Framework 29 | dioxus = { version = "0.6.3", features = ["web"] } 30 | 31 | #dioxus-free-icons = { version = "0.8.6", features = ["font-awesome-brands", "font-awesome-regular", "font-awesome-solid"] } 32 | # Apprently the above doesn't work with dioxus 0.6 yet, so we use the git version 33 | #dioxus-free-icons = { git = "https://github.com/dioxus-community/dioxus-free-icons.git", branch = "feat/dioxus-0.6", features = ["font-awesome-brands", "font-awesome-regular", "font-awesome-solid"] } 34 | dioxus-free-icons = { version = "0.9.0", features = ["font-awesome-brands", "font-awesome-regular", "font-awesome-solid"] } 35 | 36 | # Web-related 37 | web-sys = { workspace = true, features = [ 38 | "Clipboard", 39 | "Navigator", 40 | "Window", 41 | "Crypto", 42 | "Headers", 43 | "Response", 44 | "UrlSearchParams" 45 | ] } 46 | wasm-bindgen.workspace = true 47 | wasm-bindgen-futures.workspace = true 48 | lipsum = "0.9.1" 49 | 50 | # Utilities 51 | chrono.workspace = true 52 | markdown = "1.0.0-alpha.21" 53 | ciborium = "0.2.2" 54 | js-sys = "0.3.64" 55 | thiserror = "2.0.12" 56 | log = "0.4" # Add log crate 57 | 58 | # Internal dependencies 59 | river-common.workspace = true 60 | 61 | # Freenet dependencies 62 | freenet-scaffold.workspace = true 63 | freenet-stdlib = { workspace = true, features = ["net"] } 64 | 65 | futures = "0.3.30" 66 | futures-timer = "3.0.3" 67 | 68 | # Add this section for the build script 69 | [build-dependencies] 70 | chrono = "0.4" 71 | -------------------------------------------------------------------------------- /ui/Dioxus.toml: -------------------------------------------------------------------------------- 1 | [application] 2 | 3 | # App (Project) Name 4 | name = "chatui" 5 | 6 | # Dioxus App Default Platform 7 | # web, desktop, fullstack 8 | default_platform = "web" 9 | 10 | # `build` & `serve` dist path 11 | out_dir = "dist" 12 | 13 | # resource (assets) file folder 14 | asset_dir = "assets" 15 | 16 | [web.app] 17 | 18 | # base_path will be uncommented during building by the uncomment-base-path task in Makefile.toml, and then 19 | # re-commented afterwards - so except during building it should be commented out I've requested an 20 | # improvement to dioxus to avoid this, see https://github.com/DioxusLabs/dioxus/issues/3745 21 | 22 | base_path = "v1/contract/web/BcfxyjCH4snaknrBoCiqhYc9UFvmiJvhsp5d4L5DuvRa" 23 | 24 | # HTML title tag content 25 | title = "River" 26 | 27 | [web.watcher] 28 | 29 | # when watcher trigger, regenerate the `index.html` 30 | reload_html = true 31 | 32 | # which files or dirs will be watcher monitoring 33 | watch_path = ["src", "assets"] 34 | 35 | # include `assets` in web platform 36 | [web.resource] 37 | 38 | # CSS style file 39 | #style = ["bulma.min.css", "main.css", "fontawesome/css/all.min.css"] 40 | 41 | # Javascript code file 42 | script = [] 43 | 44 | # Static files to be included in the project 45 | include = ["fontawesome/webfonts"] 46 | 47 | [web.resource.dev] 48 | 49 | # Javascript code file 50 | # serve: [dev-server] only 51 | script = [] 52 | 53 | 54 | -------------------------------------------------------------------------------- /ui/assets/bulma.min.css.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/bulma.min.css.br -------------------------------------------------------------------------------- /ui/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/favicon.ico -------------------------------------------------------------------------------- /ui/assets/favicon.ico.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/favicon.ico.br -------------------------------------------------------------------------------- /ui/assets/fontawesome/css/all.min.css.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/css/all.min.css.br -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-brands-400.ttf.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-brands-400.ttf.br -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-brands-400.woff2.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-brands-400.woff2.br -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-regular-400.ttf.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-regular-400.ttf.br -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-regular-400.woff2.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-regular-400.woff2.br -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-solid-900.ttf.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-solid-900.ttf.br -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-solid-900.woff2.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-solid-900.woff2.br -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-v4compatibility.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-v4compatibility.ttf -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-v4compatibility.ttf.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-v4compatibility.ttf.br -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-v4compatibility.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-v4compatibility.woff2 -------------------------------------------------------------------------------- /ui/assets/fontawesome/webfonts/fa-v4compatibility.woff2.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/fontawesome/webfonts/fa-v4compatibility.woff2.br -------------------------------------------------------------------------------- /ui/assets/freenet_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ui/assets/freenet_logo.svg.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/freenet_logo.svg.br -------------------------------------------------------------------------------- /ui/assets/main.css.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freenet/river/0c12b63047fcf6cd2a5f189237144393e853d38b/ui/assets/main.css.br -------------------------------------------------------------------------------- /ui/build.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | 3 | fn main() { 4 | // Get the current UTC date and time 5 | let now = Utc::now(); 6 | // Use ISO 8601 format (UTC) e.g., "2023-10-27T10:30:00Z" 7 | // This is easily parseable by JavaScript's Date object. 8 | let build_timestamp_iso = now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true); 9 | 10 | // Set the BUILD_TIMESTAMP_ISO environment variable for the main crate compilation 11 | println!("cargo:rustc-env=BUILD_TIMESTAMP_ISO={}", build_timestamp_iso); 12 | 13 | // Re-run this script only if build.rs changes 14 | println!("cargo:rerun-if-changed=build.rs"); 15 | } 16 | -------------------------------------------------------------------------------- /ui/freenet.toml: -------------------------------------------------------------------------------- 1 | [contract] 2 | type = "webapp" 3 | lang = "rust" 4 | 5 | [webapp] 6 | 7 | [webapp.state-sources] 8 | source_dirs = ["dist"] 9 | -------------------------------------------------------------------------------- /ui/src/components.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod conversation; 3 | pub mod members; 4 | pub mod room_list; 5 | -------------------------------------------------------------------------------- /ui/src/components/app.rs: -------------------------------------------------------------------------------- 1 | pub mod chat_delegate; 2 | pub mod freenet_api; 3 | pub mod sync_info; 4 | 5 | use super::{conversation::Conversation, members::MemberList, room_list::RoomList}; 6 | use crate::components::app::chat_delegate::set_up_chat_delegate; 7 | use crate::components::app::freenet_api::freenet_synchronizer::SynchronizerMessage; 8 | use crate::components::app::freenet_api::freenet_synchronizer::SynchronizerStatus; 9 | use crate::components::app::freenet_api::FreenetSynchronizer; 10 | use crate::components::members::member_info_modal::MemberInfoModal; 11 | use crate::components::members::Invitation; 12 | use crate::components::room_list::create_room_modal::CreateRoomModal; 13 | use crate::components::room_list::edit_room_modal::EditRoomModal; 14 | use crate::components::room_list::receive_invitation_modal::ReceiveInvitationModal; 15 | use crate::invites::PendingInvites; 16 | use crate::room_data::{CurrentRoom, Rooms}; 17 | use dioxus::logger::tracing::{debug, error, info}; 18 | use dioxus::prelude::*; 19 | use document::Stylesheet; 20 | use ed25519_dalek::VerifyingKey; 21 | use freenet_stdlib::client_api::WebApi; 22 | use river_common::room_state::member::MemberId; 23 | use wasm_bindgen::JsCast; 24 | use wasm_bindgen_futures::spawn_local; 25 | use wasm_bindgen_futures::JsFuture; 26 | use web_sys::{window, Response}; 27 | 28 | pub static ROOMS: GlobalSignal = Global::new(initial_rooms); 29 | pub static CURRENT_ROOM: GlobalSignal = 30 | Global::new(|| CurrentRoom { owner_key: None }); 31 | pub static MEMBER_INFO_MODAL: GlobalSignal = 32 | Global::new(|| MemberInfoModalSignal { member: None }); 33 | pub static EDIT_ROOM_MODAL: GlobalSignal = 34 | Global::new(|| EditRoomModalSignal { room: None }); 35 | pub static CREATE_ROOM_MODAL: GlobalSignal = 36 | Global::new(|| CreateRoomModalSignal { show: false }); 37 | pub static PENDING_INVITES: GlobalSignal = Global::new(|| PendingInvites::new()); 38 | pub static SYNC_STATUS: GlobalSignal = 39 | Global::new(|| SynchronizerStatus::Connecting); 40 | pub static SYNCHRONIZER: GlobalSignal = 41 | Global::new(|| FreenetSynchronizer::new()); 42 | pub static WEB_API: GlobalSignal> = Global::new(|| None); 43 | pub static AUTH_TOKEN: GlobalSignal> = Global::new(|| None); 44 | 45 | #[component] 46 | pub fn App() -> Element { 47 | info!("Loaded App component"); 48 | 49 | let mut receive_invitation = use_signal(|| None::); 50 | 51 | // Read authorization header on mount and store in global 52 | // use_effect(|| { 53 | spawn_local(async { 54 | // First, try to get the auth token 55 | fetch_auth_token().await; 56 | 57 | // Now that we've tried to get the auth token, start the synchronizer 58 | debug!("Starting FreenetSynchronizer from App component"); 59 | 60 | // Start the synchronizer directly 61 | { 62 | let mut synchronizer = SYNCHRONIZER.write(); 63 | synchronizer.start().await; 64 | } 65 | 66 | let _ = set_up_chat_delegate().await; 67 | }); 68 | // }); 69 | 70 | // Check URL for invitation parameter 71 | if let Some(window) = window() { 72 | if let Ok(search) = window.location().search() { 73 | if let Some(params) = web_sys::UrlSearchParams::new_with_str(&search).ok() { 74 | if let Some(invitation_code) = params.get("invitation") { 75 | if let Ok(invitation) = Invitation::from_encoded_string(&invitation_code) { 76 | info!("Received invitation: {:?}", invitation); 77 | receive_invitation.set(Some(invitation)); 78 | } 79 | } 80 | } 81 | } 82 | } 83 | 84 | #[cfg(not(feature = "no-sync"))] 85 | { 86 | // The synchronizer is now started in the auth token effect 87 | 88 | // Add use_effect to watch for changes to rooms and trigger synchronization 89 | use_effect(move || { 90 | // This will run whenever rooms changes 91 | debug!("Rooms state changed, triggering synchronization"); 92 | 93 | // Get all the data we need upfront to avoid nested borrows 94 | let message_sender = SYNCHRONIZER.read().get_message_sender(); 95 | let has_rooms = !ROOMS.read().map.is_empty(); 96 | let has_invitations = !PENDING_INVITES.read().map.is_empty(); 97 | 98 | if has_rooms || has_invitations { 99 | info!("Change detected, sending ProcessRooms message to synchronizer, has_rooms={}, has_invitations={}", has_rooms, has_invitations); 100 | if let Err(e) = message_sender.unbounded_send(SynchronizerMessage::ProcessRooms) { 101 | error!("Failed to send ProcessRooms message: {}", e); 102 | } 103 | 104 | // Also save rooms to delegate when they change 105 | // Use spawn_local to avoid blocking the UI thread 106 | spawn_local(async { 107 | if let Err(e) = chat_delegate::save_rooms_to_delegate().await { 108 | error!("Failed to save rooms to delegate: {}", e); 109 | } 110 | }); 111 | } else { 112 | debug!("No rooms to synchronize"); 113 | } 114 | 115 | // No need to return anything 116 | }); 117 | 118 | info!("FreenetSynchronizer setup complete"); 119 | } 120 | 121 | rsx! { 122 | Stylesheet { href: asset!("./assets/bulma.min.css") } 123 | Stylesheet { href: asset!("./assets/main.css") } 124 | Stylesheet { href: asset!("./assets/fontawesome/css/all.min.css") } 125 | 126 | // Status indicator for Freenet connection 127 | div { 128 | class: match &*SYNC_STATUS.read() { 129 | SynchronizerStatus::Connected => "connection-status connected", 130 | SynchronizerStatus::Connecting => "connection-status connecting", 131 | SynchronizerStatus::Disconnected => "connection-status disconnected", 132 | SynchronizerStatus::Error(_) => "connection-status error", 133 | }, 134 | div { class: "status-icon" } 135 | div { class: "status-text", 136 | { 137 | match &*SYNC_STATUS.read() { 138 | SynchronizerStatus::Connected => "Connected".to_string(), 139 | SynchronizerStatus::Connecting => "Connecting...".to_string(), 140 | SynchronizerStatus::Disconnected => "Disconnected".to_string(), 141 | SynchronizerStatus::Error(ref msg) => format!("Error: {}", msg), 142 | } 143 | } 144 | } 145 | } 146 | 147 | // No longer needed - using the invite button in the members list instead 148 | 149 | div { class: "chat-container", 150 | RoomList {} 151 | Conversation {} 152 | MemberList {} 153 | } 154 | EditRoomModal {} 155 | MemberInfoModal {} 156 | CreateRoomModal {} 157 | ReceiveInvitationModal { 158 | invitation: receive_invitation 159 | } 160 | } 161 | } 162 | 163 | #[cfg(not(feature = "example-data"))] 164 | fn initial_rooms() -> Rooms { 165 | Rooms { 166 | map: std::collections::HashMap::new(), 167 | } 168 | } 169 | 170 | #[cfg(feature = "example-data")] 171 | fn initial_rooms() -> Rooms { 172 | crate::example_data::create_example_rooms() 173 | } 174 | 175 | pub struct EditRoomModalSignal { 176 | pub room: Option, 177 | } 178 | 179 | pub struct CreateRoomModalSignal { 180 | pub show: bool, 181 | } 182 | 183 | pub struct MemberInfoModalSignal { 184 | pub member: Option, 185 | } 186 | 187 | /// Fetches the authorization token from the current page's headers 188 | /// and stores it in the AUTH_TOKEN global signal 189 | async fn fetch_auth_token() { 190 | if let Some(win) = window() { 191 | let href = win.location().href().unwrap_or_default(); 192 | 193 | match JsFuture::from(win.fetch_with_str(&href)).await { 194 | Ok(resp_value) => { 195 | if let Ok(resp) = resp_value.dyn_into::() { 196 | if let Ok(Some(token)) = resp.headers().get("authorization") { 197 | info!("Found auth token: {}", token); 198 | 199 | // Extract the token part without the "Bearer" prefix 200 | if token.starts_with("Bearer ") { 201 | let token_part = token.trim_start_matches("Bearer ").trim(); 202 | *AUTH_TOKEN.write() = Some(token_part.to_string()); 203 | debug!("Stored token value: {}", token_part); 204 | } else { 205 | // If it doesn't have the expected format, store as-is 206 | *AUTH_TOKEN.write() = Some(token); 207 | } 208 | } else { 209 | debug!("Authorization header missing or not exposed"); 210 | } 211 | } 212 | } 213 | Err(err) => { 214 | error!("Failed to fetch page for auth header: {:?}", err); 215 | } 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /ui/src/components/app/chat_delegate.rs: -------------------------------------------------------------------------------- 1 | use crate::components::app::{ROOMS, WEB_API}; 2 | use dioxus::logger::tracing::{info, warn}; 3 | use dioxus::prelude::Readable; 4 | use freenet_stdlib::client_api::ClientRequest::DelegateOp; 5 | use freenet_stdlib::client_api::DelegateRequest; 6 | use freenet_stdlib::prelude::{ 7 | ContractInstanceId, Delegate, DelegateCode, DelegateContainer, DelegateWasmAPIVersion, 8 | Parameters, 9 | }; 10 | use river_common::chat_delegate::{ 11 | ChatDelegateKey, ChatDelegateRequestMsg, ChatDelegateResponseMsg, 12 | }; 13 | 14 | // Constant for the rooms storage key 15 | pub const ROOMS_STORAGE_KEY: &[u8] = b"rooms_data"; 16 | /* 17 | pub async fn set_up_chat_delegate() -> Result<(), String> { 18 | let delegate = create_chat_delegate_container(); 19 | 20 | // Get a write lock on the API and use it directly 21 | let api_result = { 22 | let mut web_api = WEB_API.write(); 23 | if let Some(api) = web_api.as_mut() { 24 | // Perform the operation while holding the lock 25 | info!("Registering chat delegate"); 26 | api.send(DelegateOp(DelegateRequest::RegisterDelegate { 27 | delegate, 28 | cipher: DelegateRequest::DEFAULT_CIPHER, 29 | nonce: DelegateRequest::DEFAULT_NONCE, 30 | })) 31 | .await 32 | } else { 33 | Err(freenet_stdlib::client_api::Error::ConnectionClosed) 34 | } 35 | }; 36 | 37 | match api_result { 38 | Ok(_) => { 39 | info!("Chat delegate registered successfully"); 40 | load_rooms_from_delegate().await?; 41 | Ok(()) 42 | } 43 | Err(e) => Err(format!("Failed to register chat delegate: {}", e)), 44 | } 45 | } 46 | 47 | /// Load rooms from the delegate storage 48 | pub async fn load_rooms_from_delegate() -> Result<(), String> { 49 | info!("Loading rooms from delegate storage"); 50 | 51 | // Create a get request for the rooms data 52 | let request = ChatDelegateRequestMsg::GetRequest { 53 | key: ChatDelegateKey::new(ROOMS_STORAGE_KEY.to_vec()), 54 | }; 55 | 56 | // Send the request to the delegate 57 | match send_delegate_request(request).await { 58 | Ok(_) => { 59 | info!("Sent request to load rooms from delegate"); 60 | Ok(()) 61 | } 62 | Err(e) => { 63 | warn!("Failed to load rooms from delegate: {}", e); 64 | // Don't fail the app if we can't load rooms 65 | Ok(()) 66 | } 67 | } 68 | } 69 | 70 | /// Save rooms to the delegate storage 71 | pub async fn save_rooms_to_delegate() -> Result<(), String> { 72 | info!("Saving rooms to delegate storage"); 73 | 74 | // Get the current rooms data - clone the data to avoid holding the read lock 75 | let rooms_data = { 76 | let rooms_clone = ROOMS.read().clone(); 77 | let mut buffer = Vec::new(); 78 | ciborium::ser::into_writer(&rooms_clone, &mut buffer) 79 | .map_err(|e| format!("Failed to serialize rooms: {}", e))?; 80 | buffer 81 | }; 82 | 83 | // Create a store request for the rooms data 84 | let request = ChatDelegateRequestMsg::StoreRequest { 85 | key: ChatDelegateKey::new(ROOMS_STORAGE_KEY.to_vec()), 86 | value: rooms_data, 87 | }; 88 | 89 | // Send the request to the delegate 90 | match send_delegate_request(request).await { 91 | Ok(ChatDelegateResponseMsg::StoreResponse { result, .. }) => result, 92 | Ok(other) => Err(format!("Unexpected response: {:?}", other)), 93 | Err(e) => Err(e), 94 | } 95 | } 96 | 97 | fn create_chat_delegate_container() -> DelegateContainer { 98 | let delegate_bytes = 99 | include_bytes!("../../../../target/wasm32-unknown-unknown/release/chat_delegate.wasm"); 100 | let delegate_code = DelegateCode::from(delegate_bytes.to_vec()); 101 | let params = Parameters::from(Vec::::new()); 102 | let delegate = Delegate::from((&delegate_code, ¶ms)); 103 | DelegateContainer::Wasm(DelegateWasmAPIVersion::V1(delegate)) 104 | } 105 | 106 | pub async fn send_delegate_request( 107 | request: ChatDelegateRequestMsg, 108 | ) -> Result { 109 | info!("Sending delegate request: {:?}", request); 110 | 111 | // Serialize the request 112 | let mut payload = Vec::new(); 113 | ciborium::ser::into_writer(&request, &mut payload) 114 | .map_err(|e| format!("Failed to serialize request: {}", e))?; 115 | 116 | info!("Serialized request payload size: {} bytes", payload.len()); 117 | 118 | let delegate_code = DelegateCode::from( 119 | include_bytes!("../../../../target/wasm32-unknown-unknown/release/chat_delegate.wasm") 120 | .to_vec(), 121 | ); 122 | let params = Parameters::from(Vec::::new()); 123 | let delegate = Delegate::from((&delegate_code, ¶ms)); 124 | let delegate_key = delegate.key().clone(); // Get the delegate key for targeting the delegate request 125 | 126 | // FIXME: Not sure what this should be set to in this context 127 | let self_contract_id = ContractInstanceId::new([0u8; 32]); 128 | 129 | let app_msg = freenet_stdlib::prelude::ApplicationMessage::new(self_contract_id, payload); 130 | 131 | // Prepare the delegate request, targeting the delegate using its key 132 | let delegate_request = DelegateOp(DelegateRequest::ApplicationMessages { 133 | key: delegate_key, // Target the delegate instance 134 | params: Parameters::from(Vec::::new()), 135 | inbound: vec![freenet_stdlib::prelude::InboundDelegateMsg::ApplicationMessage(app_msg)], 136 | }); 137 | 138 | // Get the API and send the request, releasing the lock before awaiting 139 | let api_result = { 140 | let mut web_api = WEB_API.write(); 141 | if let Some(api) = web_api.as_mut() { 142 | // Send the request while holding the lock 143 | api.send(delegate_request).await 144 | } else { 145 | Err(freenet_stdlib::client_api::Error::ConnectionClosed) 146 | } 147 | }; 148 | 149 | // Handle the response 150 | api_result.map_err(|e| format!("Failed to send delegate request: {}", e))?; 151 | 152 | // For now, we'll just return a placeholder response since we don't have a way to get the actual response 153 | // In a real implementation, we would need to set up a way to receive the response from the delegate 154 | match request { 155 | ChatDelegateRequestMsg::StoreRequest { key, .. } => { 156 | Ok(ChatDelegateResponseMsg::StoreResponse { 157 | key, 158 | result: Ok(()), 159 | value_size: 0, 160 | }) 161 | } 162 | ChatDelegateRequestMsg::GetRequest { key } => { 163 | Ok(ChatDelegateResponseMsg::GetResponse { key, value: None }) 164 | } 165 | ChatDelegateRequestMsg::DeleteRequest { key } => { 166 | Ok(ChatDelegateResponseMsg::DeleteResponse { 167 | key, 168 | result: Ok(()), 169 | }) 170 | } 171 | ChatDelegateRequestMsg::ListRequest => { 172 | Ok(ChatDelegateResponseMsg::ListResponse { keys: Vec::new() }) 173 | } 174 | } 175 | } 176 | */ 177 | 178 | // No-op versions of the public functions above - temporarily disabled for debugging 179 | 180 | pub async fn set_up_chat_delegate() -> Result<(), String> { 181 | warn!("Chat delegate setup is disabled for debugging"); 182 | Ok(()) 183 | } 184 | 185 | pub async fn load_rooms_from_delegate() -> Result<(), String> { 186 | warn!("Loading rooms from delegate is disabled for debugging"); 187 | Ok(()) 188 | } 189 | 190 | pub async fn save_rooms_to_delegate() -> Result<(), String> { 191 | warn!("Saving rooms to delegate is disabled for debugging"); 192 | Ok(()) 193 | } 194 | 195 | // -------------------------------------------------------------------------------- /ui/src/components/app/freenet_api.rs: -------------------------------------------------------------------------------- 1 | //! Freenet API integration for chat room synchronization 2 | //! 3 | //! Handles WebSocket communication with Freenet network, manages room subscriptions, 4 | //! and processes state updates. 5 | 6 | pub mod connection_manager; 7 | mod constants; 8 | pub mod error; 9 | pub mod freenet_synchronizer; 10 | pub mod response_handler; 11 | pub mod room_synchronizer; 12 | 13 | pub use freenet_synchronizer::FreenetSynchronizer; 14 | -------------------------------------------------------------------------------- /ui/src/components/app/freenet_api/connection_manager.rs: -------------------------------------------------------------------------------- 1 | use super::constants::*; 2 | use super::error::SynchronizerError; 3 | use super::freenet_synchronizer; 4 | use crate::components::app::freenet_api::freenet_synchronizer::SynchronizerStatus; 5 | use crate::components::app::{AUTH_TOKEN, SYNC_STATUS, WEB_API}; 6 | use crate::util::sleep; 7 | use dioxus::logger::tracing::{error, info}; 8 | use dioxus::prelude::*; 9 | use freenet_stdlib::client_api::WebApi; 10 | use futures::channel::mpsc::UnboundedSender; 11 | use std::time::Duration; 12 | use wasm_bindgen_futures::spawn_local; 13 | 14 | /// Manages the connection to the Freenet node 15 | pub struct ConnectionManager { 16 | connected: bool, 17 | } 18 | 19 | impl ConnectionManager { 20 | pub fn new() -> Self { 21 | info!("Creating new ConnectionManager"); 22 | Self { connected: false } 23 | } 24 | 25 | /// Check if the connection is established and ready 26 | pub fn is_connected(&self) -> bool { 27 | *SYNC_STATUS.read() == SynchronizerStatus::Connected 28 | } 29 | 30 | /// Initializes the connection to the Freenet node 31 | pub async fn initialize_connection( 32 | &mut self, 33 | message_tx: UnboundedSender, 34 | ) -> Result<(), SynchronizerError> { 35 | // Get auth token to add as query parameter 36 | let auth_token = AUTH_TOKEN.read().clone(); 37 | let websocket_url = if let Some(token) = auth_token { 38 | // Check if the URL already has query parameters 39 | if WEBSOCKET_URL.contains('?') { 40 | format!("{}&authToken={}", WEBSOCKET_URL, token) 41 | } else { 42 | format!("{}?authToken={}", WEBSOCKET_URL, token) 43 | } 44 | } else { 45 | WEBSOCKET_URL.to_string() 46 | }; 47 | 48 | info!("Connecting to Freenet node at: {}", websocket_url); 49 | *SYNC_STATUS.write() = SynchronizerStatus::Connecting; 50 | self.connected = false; 51 | 52 | info!("Connecting to WebSocket URL: {}", websocket_url); 53 | let websocket = web_sys::WebSocket::new(&websocket_url).map_err(|e| { 54 | let error_msg = format!("Failed to create WebSocket: {:?}", e); 55 | error!("{}", error_msg); 56 | SynchronizerError::WebSocketError(error_msg) 57 | })?; 58 | 59 | // Create a simple oneshot channel for connection readiness 60 | let (ready_tx, ready_rx) = futures::channel::oneshot::channel(); 61 | let message_tx_clone = message_tx.clone(); 62 | 63 | // No need to create a reference to self.connected since we're not using it in callbacks 64 | 65 | info!("Starting WebAPI with callbacks"); 66 | 67 | // Start the WebAPI 68 | let web_api = WebApi::start( 69 | websocket.clone(), 70 | move |result| { 71 | let mapped_result = 72 | result.map_err(|e| SynchronizerError::WebSocketError(e.to_string())); 73 | let tx = message_tx_clone.clone(); 74 | spawn_local(async move { 75 | if let Err(e) = tx.unbounded_send( 76 | freenet_synchronizer::SynchronizerMessage::ApiResponse(mapped_result), 77 | ) { 78 | error!("Failed to send API response: {}", e); 79 | } 80 | }); 81 | }, 82 | { 83 | move |error| { 84 | let error_msg = format!("WebSocket error: {}", error); 85 | error!("{}", error_msg); 86 | spawn_local(async move { 87 | *SYNC_STATUS.write() = 88 | freenet_synchronizer::SynchronizerStatus::Error(error_msg); 89 | }); 90 | } 91 | }, 92 | { 93 | move || { 94 | info!("WebSocket connected successfully"); 95 | spawn_local(async move { 96 | *SYNC_STATUS.write() = freenet_synchronizer::SynchronizerStatus::Connected; 97 | }); 98 | let _ = ready_tx.send(()); 99 | } 100 | }, 101 | ); 102 | 103 | // Wait for connection with timeout 104 | info!( 105 | "Waiting for connection with timeout of {}ms", 106 | CONNECTION_TIMEOUT_MS 107 | ); 108 | 109 | // Create a timeout future 110 | let timeout_future = sleep(Duration::from_millis(CONNECTION_TIMEOUT_MS)); 111 | 112 | // Race the ready signal against the timeout 113 | let result = futures::future::select(Box::pin(ready_rx), Box::pin(timeout_future)).await; 114 | 115 | match result { 116 | futures::future::Either::Left((Ok(_), _)) => { 117 | info!("WebSocket connection established successfully"); 118 | *WEB_API.write() = Some(web_api); 119 | self.connected = true; 120 | *SYNC_STATUS.write() = SynchronizerStatus::Connected; 121 | 122 | // Now that we're connected, send the auth token 123 | /* Disabled because it's generating an error from the API, use URL query param instead above 124 | let auth_token = AUTH_TOKEN.read().clone(); 125 | if let Some(token) = auth_token { 126 | info!("Sending auth token to WebSocket"); 127 | if let Some(api) = &mut *WEB_API.write() { 128 | match api.send(ClientRequest::Authenticate { token }).await { 129 | Ok(_) => info!("Authentication token sent successfully"), 130 | Err(e) => { 131 | // Check if this is a "not supported" error 132 | if e.to_string().contains("not supported") { 133 | warn!("Authentication method not supported by server. This may indicate API version mismatch."); 134 | // Continue anyway as some operations might still work 135 | info!("Continuing despite authentication error"); 136 | } else { 137 | return Err(e.into()); 138 | } 139 | } 140 | } 141 | } 142 | } */ 143 | 144 | Ok(()) 145 | } 146 | _ => { 147 | let error = SynchronizerError::WebSocketError( 148 | "WebSocket connection failed or timed out".to_string(), 149 | ); 150 | error!("{}", error); 151 | self.connected = false; 152 | *SYNC_STATUS.write() = 153 | freenet_synchronizer::SynchronizerStatus::Error(error.to_string()); 154 | 155 | // Schedule reconnect 156 | let tx = message_tx.clone(); 157 | spawn_local(async move { 158 | info!( 159 | "Scheduling reconnection attempt in {}ms", 160 | RECONNECT_INTERVAL_MS 161 | ); 162 | sleep(Duration::from_millis(RECONNECT_INTERVAL_MS)).await; 163 | info!("Attempting reconnection now"); 164 | if let Err(e) = 165 | tx.unbounded_send(freenet_synchronizer::SynchronizerMessage::Connect) 166 | { 167 | error!("Failed to send reconnect message: {}", e); 168 | } 169 | }); 170 | 171 | Err(error) 172 | } 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /ui/src/components/app/freenet_api/constants.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | /// WebSocket URL for connecting to local Freenet node 4 | pub const WEBSOCKET_URL: &str = "ws://localhost:50509/v1/contract/command?encodingProtocol=native"; 5 | 6 | /// Default timeout for WebSocket connection in milliseconds 7 | pub const CONNECTION_TIMEOUT_MS: u64 = 5000; 8 | 9 | /// Delay after PUT before subscribing to a room in milliseconds 10 | pub const POST_PUT_DELAY_MS: u64 = 3000; 11 | 12 | /// Retry interval for reconnection attempts in milliseconds 13 | pub const RECONNECT_INTERVAL_MS: u64 = 3000; 14 | 15 | /// Maximum number of retries for API requests 16 | pub const MAX_REQUEST_RETRIES: u8 = 3; 17 | -------------------------------------------------------------------------------- /ui/src/components/app/freenet_api/error.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use freenet_stdlib::client_api; 4 | use thiserror::Error; 5 | 6 | /// Error types for the Freenet synchronizer 7 | #[derive(Error, Debug, Clone)] 8 | pub enum SynchronizerError { 9 | #[error("WebSocket connection error: {0}")] 10 | WebSocketError(String), 11 | 12 | #[error("WebSocket operation not supported: {0}")] 13 | WebSocketNotSupported(String), 14 | 15 | #[error("Connection timeout after {0}ms")] 16 | ConnectionTimeout(u64), 17 | 18 | #[error("API not initialized")] 19 | ApiNotInitialized, 20 | 21 | #[error("Room data not found for key: {0}")] 22 | RoomNotFound(String), 23 | 24 | #[error("Contract info not found for key: {0}")] 25 | ContractInfoNotFound(String), 26 | 27 | #[error("Failed to send message: {0}")] 28 | MessageSendError(String), 29 | 30 | #[error("Failed to merge room state: {0}")] 31 | StateMergeError(String), 32 | 33 | #[error("Failed to apply delta to room state: {0}")] 34 | DeltaApplyError(String), 35 | 36 | #[error("Failed to put contract state: {0}")] 37 | PutContractError(String), 38 | 39 | #[error("Failed to subscribe to contract: {0}")] 40 | SubscribeError(String), 41 | 42 | #[error("Serialization error: {0}")] 43 | SerializationError(String), 44 | 45 | #[error("Deserialization error: {0}")] 46 | DeserializationError(String), 47 | 48 | #[error("Client API error: {0}")] 49 | ClientApiError(String), 50 | 51 | #[error("Unknown error: {0}")] 52 | Unknown(String), 53 | } 54 | 55 | impl From for SynchronizerError { 56 | fn from(error: String) -> Self { 57 | SynchronizerError::Unknown(error) 58 | } 59 | } 60 | 61 | impl From<&str> for SynchronizerError { 62 | fn from(error: &str) -> Self { 63 | SynchronizerError::Unknown(error.to_string()) 64 | } 65 | } 66 | 67 | impl From for SynchronizerError { 68 | fn from(error: client_api::Error) -> Self { 69 | SynchronizerError::ClientApiError(error.to_string()) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /ui/src/components/app/freenet_api/response_handler/get_response.rs: -------------------------------------------------------------------------------- 1 | use crate::components::app::freenet_api::error::SynchronizerError; 2 | use crate::components::app::freenet_api::room_synchronizer::RoomSynchronizer; 3 | use crate::components::app::sync_info::{RoomSyncStatus, SYNC_INFO}; 4 | use crate::components::app::{CURRENT_ROOM, PENDING_INVITES, ROOMS}; 5 | use crate::invites::PendingRoomStatus; 6 | use crate::room_data::RoomData; 7 | use crate::util::{from_cbor_slice, owner_vk_to_contract_key}; 8 | use dioxus::logger::tracing::{error, info, warn}; 9 | use dioxus::prelude::Readable; 10 | use freenet_scaffold::ComposableState; 11 | use freenet_stdlib::prelude::ContractKey; 12 | use river_common::room_state::member::{MemberId, MembersDelta}; 13 | use river_common::room_state::member_info::{AuthorizedMemberInfo, MemberInfo}; 14 | use river_common::room_state::{ChatRoomParametersV1, ChatRoomStateV1, ChatRoomStateV1Delta}; 15 | 16 | pub async fn handle_get_response( 17 | room_synchronizer: &mut RoomSynchronizer, 18 | key: ContractKey, 19 | _contract: Vec, 20 | state: Vec, 21 | ) -> Result<(), SynchronizerError> { 22 | info!("Received get response for key {key}"); 23 | 24 | // First try to find the owner_vk from SYNC_INFO 25 | let owner_vk = SYNC_INFO.read().get_owner_vk_for_instance_id(&key.id()); 26 | 27 | // If we couldn't find it in SYNC_INFO, try to find it in PENDING_INVITES by checking contract keys 28 | let owner_vk = if owner_vk.is_none() { 29 | // This is a fallback mechanism in case SYNC_INFO wasn't properly set up 30 | warn!( 31 | "Owner VK not found in SYNC_INFO for contract ID: {}, trying fallback", 32 | key.id() 33 | ); 34 | 35 | let pending_invites = PENDING_INVITES.read(); 36 | let mut found_owner_vk = None; 37 | 38 | for (owner_key, _) in pending_invites.map.iter() { 39 | let contract_key = owner_vk_to_contract_key(owner_key); 40 | if contract_key.id() == key.id() { 41 | info!( 42 | "Found matching owner key in pending invites: {:?}", 43 | MemberId::from(*owner_key) 44 | ); 45 | found_owner_vk = Some(*owner_key); 46 | break; 47 | } 48 | } 49 | 50 | found_owner_vk 51 | } else { 52 | owner_vk 53 | }; 54 | 55 | // Now check if this is for a pending invitation 56 | if let Some(owner_vk) = owner_vk { 57 | if PENDING_INVITES.read().map.contains_key(&owner_vk) { 58 | info!("This is a subscription for a pending invitation, adding state"); 59 | let retrieved_state: ChatRoomStateV1 = from_cbor_slice::(&*state); 60 | 61 | // Get the pending invite data once to avoid multiple reads 62 | let (self_sk, authorized_member, preferred_nickname) = { 63 | let pending_invites = PENDING_INVITES.read(); 64 | let invite = &pending_invites.map[&owner_vk]; 65 | ( 66 | invite.invitee_signing_key.clone(), 67 | invite.authorized_member.clone(), 68 | invite.preferred_nickname.clone(), 69 | ) 70 | }; 71 | 72 | // Prepare the member ID for checking 73 | let member_id: MemberId = authorized_member.member.member_vk.into(); 74 | 75 | // Update the room data 76 | ROOMS.with_mut(|rooms| { 77 | // Get the entry for this room 78 | let entry = rooms.map.entry(owner_vk); 79 | 80 | // Check if this is a new entry before inserting 81 | let is_new_entry = matches!(entry, std::collections::hash_map::Entry::Vacant(_)); 82 | 83 | // Insert or get the existing room data 84 | let room_data = entry.or_insert_with(|| { 85 | // Create new room data if it doesn't exist 86 | RoomData { 87 | owner_vk, 88 | room_state: retrieved_state.clone(), 89 | self_sk: self_sk.clone(), 90 | contract_key: key.clone(), 91 | } 92 | }); 93 | 94 | // If the room already existed, merge the retrieved state 95 | if !is_new_entry { 96 | // Create parameters for merge 97 | let params = ChatRoomParametersV1 { owner: owner_vk }; 98 | 99 | // Clone current state to avoid borrow issues during merge 100 | let current_state = room_data.room_state.clone(); 101 | 102 | // Merge the retrieved state into the existing state 103 | room_data 104 | .room_state 105 | .merge(¤t_state, ¶ms, &retrieved_state) 106 | .expect("Failed to merge room states"); 107 | } 108 | 109 | // Set the member's nickname in member_info regardless of whether they were already in the room 110 | // This ensures the member has corresponding MemberInfo even if they were already a member 111 | let member_info = MemberInfo { 112 | member_id, 113 | version: 0, 114 | preferred_nickname: preferred_nickname.clone(), 115 | }; 116 | 117 | let authorized_member_info = 118 | AuthorizedMemberInfo::new_with_member_key(member_info.clone(), &self_sk); 119 | 120 | // Create a Delta from the invitation and merge it to ensure that the 121 | // relevant information is part of the state 122 | 123 | let invitation_delta = ChatRoomStateV1Delta { 124 | configuration: None, 125 | bans: None, 126 | members: Some(MembersDelta::new(vec![authorized_member.clone()])), 127 | member_info: Some(vec![authorized_member_info]), 128 | recent_messages: None, 129 | upgrade: None, 130 | }; 131 | 132 | // Clone current state to avoid borrow issues during merge 133 | let current_state = room_data.room_state.clone(); 134 | 135 | room_data 136 | .room_state 137 | .apply_delta( 138 | ¤t_state, 139 | &ChatRoomParametersV1 { owner: owner_vk }, 140 | &Some(invitation_delta), 141 | ) 142 | .expect("Failed to apply invitation delta"); 143 | }); 144 | 145 | // Make sure SYNC_INFO is properly set up for this room 146 | SYNC_INFO.with_mut(|sync_info| { 147 | // Register the room if it wasn't already registered 148 | sync_info.register_new_room(owner_vk); 149 | 150 | // DO NOT update the last_synced_state here 151 | // This will ensure the room is marked as needing an update in the next synchronization 152 | 153 | // Update the sync status 154 | sync_info.update_sync_status(&owner_vk, RoomSyncStatus::Subscribed); 155 | }); 156 | 157 | // Now subscribe to the contract 158 | let subscribe_result = room_synchronizer.subscribe_to_contract(&key).await; 159 | 160 | if let Err(e) = subscribe_result { 161 | error!("Failed to subscribe to contract after GET: {}", e); 162 | // Update the sync status to error 163 | SYNC_INFO 164 | .write() 165 | .update_sync_status(&owner_vk, RoomSyncStatus::Error(e.to_string())); 166 | } else { 167 | // Mark the invitation as subscribed and retrieved 168 | PENDING_INVITES.with_mut(|pending_invites| { 169 | if let Some(join) = pending_invites.map.get_mut(&owner_vk) { 170 | join.status = PendingRoomStatus::Subscribed; 171 | } 172 | }); 173 | } 174 | 175 | // Dispatch an event to notify the UI 176 | if let Some(window) = web_sys::window() { 177 | let key_hex = owner_vk 178 | .as_bytes() 179 | .iter() 180 | .map(|b| format!("{:02x}", b)) 181 | .collect::(); 182 | let event = web_sys::CustomEvent::new("river-invitation-accepted").unwrap(); 183 | 184 | // Set the detail property 185 | js_sys::Reflect::set( 186 | &event, 187 | &wasm_bindgen::JsValue::from_str("detail"), 188 | &wasm_bindgen::JsValue::from_str(&key_hex), 189 | ) 190 | .unwrap(); 191 | 192 | window.dispatch_event(&event).unwrap(); 193 | 194 | // Set the current room to the newly accepted room 195 | CURRENT_ROOM.with_mut(|current_room| { 196 | current_room.owner_key = Some(owner_vk); 197 | }); 198 | } 199 | } 200 | } 201 | 202 | Ok(()) 203 | } 204 | -------------------------------------------------------------------------------- /ui/src/components/app/freenet_api/response_handler/put_response.rs: -------------------------------------------------------------------------------- 1 | use crate::components::app::freenet_api::error::SynchronizerError; 2 | use crate::components::app::freenet_api::room_synchronizer::RoomSynchronizer; 3 | use crate::components::app::sync_info::{RoomSyncStatus, SYNC_INFO}; 4 | use crate::components::app::ROOMS; 5 | use crate::util::owner_vk_to_contract_key; 6 | use dioxus::logger::tracing::{error, info, warn}; 7 | use dioxus::prelude::Readable; 8 | use freenet_stdlib::prelude::ContractKey; 9 | use river_common::room_state::member::MemberId; 10 | 11 | pub async fn handle_put_response( 12 | room_synchronizer: &mut RoomSynchronizer, 13 | key: ContractKey, 14 | ) -> Result<(), SynchronizerError> { 15 | let contract_id = key.id(); 16 | info!("Received PutResponse for contract ID: {}", contract_id); 17 | 18 | // Get the owner VK first, then release the read lock 19 | let owner_vk_opt = { 20 | let sync_info = SYNC_INFO.read(); 21 | sync_info.get_owner_vk_for_instance_id(&contract_id) 22 | }; 23 | 24 | match owner_vk_opt { 25 | Some(owner_vk) => { 26 | info!( 27 | "Found owner VK for contract ID {}: {:?}", 28 | contract_id, 29 | MemberId::from(owner_vk) 30 | ); 31 | 32 | // Now subscribe to the contract 33 | let subscribe_result = room_synchronizer.subscribe_to_contract(&key).await; 34 | 35 | if let Err(e) = subscribe_result { 36 | error!("Failed to subscribe to contract after PUT: {}", e); 37 | // Update the sync status to error 38 | SYNC_INFO 39 | .write() 40 | .update_sync_status(&owner_vk, RoomSyncStatus::Error(e.to_string())); 41 | } else { 42 | // Update sync status in a separate block to avoid nested borrows 43 | SYNC_INFO 44 | .write() 45 | .update_sync_status(&owner_vk, RoomSyncStatus::Subscribed); 46 | } 47 | 48 | // Log the current state of all rooms after successful PUT 49 | let rooms_count = { 50 | let rooms = ROOMS.read(); 51 | let count = rooms.map.len(); 52 | count 53 | }; 54 | info!("Current rooms count after PutResponse: {}", rooms_count); 55 | 56 | // Get room information in a separate block 57 | let room_info: Vec<(MemberId, String)> = { 58 | let rooms = ROOMS.read(); 59 | rooms 60 | .map 61 | .iter() 62 | .map(|(room_key, _)| { 63 | let contract_key = owner_vk_to_contract_key(room_key); 64 | let room_contract_id = contract_key.id(); 65 | (MemberId::from(*room_key), room_contract_id.to_string()) 66 | }) 67 | .collect() 68 | }; 69 | 70 | // Log room information 71 | for (member_id, contract_id) in room_info { 72 | info!("Room in map: {:?}, contract ID: {}", member_id, contract_id); 73 | } 74 | } 75 | None => { 76 | warn!( 77 | "Warning: Could ntot find owner VK for contract ID: {}", 78 | contract_id 79 | ); 80 | } 81 | } 82 | 83 | Ok(()) 84 | } 85 | -------------------------------------------------------------------------------- /ui/src/components/app/freenet_api/response_handler/subscribe_response.rs: -------------------------------------------------------------------------------- 1 | use crate::components::app::sync_info::{RoomSyncStatus, SYNC_INFO}; 2 | use dioxus::logger::tracing::{info, warn}; 3 | use dioxus::prelude::Readable; 4 | use freenet_stdlib::prelude::ContractKey; 5 | use river_common::room_state::member::MemberId; 6 | 7 | pub fn handle_subscribe_response(key: ContractKey, subscribed: bool) { 8 | info!("Received subscribe response for key {key}, subscribed: {subscribed}"); 9 | 10 | // Get the owner VK for this contract first, then release the read lock 11 | let owner_vk_opt = { 12 | let sync_info = SYNC_INFO.read(); 13 | sync_info 14 | .get_owner_vk_for_instance_id(&key.id()) 15 | .map(|vk| vk) 16 | }; 17 | 18 | if let Some(owner_vk) = owner_vk_opt { 19 | if subscribed { 20 | info!( 21 | "Successfully subscribed to contract for room: {:?}", 22 | MemberId::from(owner_vk) 23 | ); 24 | 25 | // Update the sync status to subscribed in a separate block 26 | SYNC_INFO 27 | .write() 28 | .update_sync_status(&owner_vk, RoomSyncStatus::Subscribed); 29 | } else { 30 | warn!("Failed to subscribe to contract: {}", key.id()); 31 | // Update the sync status to error 32 | SYNC_INFO.write().update_sync_status( 33 | &owner_vk, 34 | RoomSyncStatus::Error("Subscription failed".to_string()), 35 | ); 36 | } 37 | } else { 38 | warn!("Could not find owner VK for contract ID: {}", key.id()); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ui/src/components/app/freenet_api/response_handler/update_notification.rs: -------------------------------------------------------------------------------- 1 | use crate::components::app::freenet_api::error::SynchronizerError; 2 | use crate::components::app::freenet_api::room_synchronizer::RoomSynchronizer; 3 | use crate::components::app::sync_info::SYNC_INFO; 4 | use crate::util::from_cbor_slice; 5 | use dioxus::logger::tracing::{info, warn}; 6 | use dioxus::prelude::Readable; 7 | use freenet_stdlib::prelude::{ContractKey, UpdateData}; 8 | use river_common::room_state::{ChatRoomStateV1, ChatRoomStateV1Delta}; 9 | 10 | pub fn handle_update_notification( 11 | room_synchronizer: &mut RoomSynchronizer, 12 | key: ContractKey, 13 | update: UpdateData, 14 | ) -> Result<(), SynchronizerError> { 15 | info!("Received update notification for key: {key}"); 16 | // Get contract info, log warning and return early if not found 17 | // Get contract info, return early if not found 18 | let room_owner_vk = match SYNC_INFO.read().get_owner_vk_for_instance_id(&key.id()) { 19 | Some(vk) => vk, 20 | None => { 21 | warn!("Contract key not found in SYNC_INFO: {}", key.id()); 22 | return Ok(()); 23 | } 24 | }; 25 | 26 | // Handle update notification 27 | match update { 28 | UpdateData::State(state) => { 29 | let new_state: ChatRoomStateV1 = from_cbor_slice::(&*state); 30 | 31 | // Regular state update 32 | info!("Received new state in UpdateNotification: {:?}", new_state); 33 | room_synchronizer.update_room_state(&room_owner_vk, &new_state); 34 | } 35 | UpdateData::Delta(delta) => { 36 | let new_delta: ChatRoomStateV1Delta = from_cbor_slice::(&*delta); 37 | info!("Received new delta in UpdateNotification: {:?}", new_delta); 38 | room_synchronizer.apply_delta(&room_owner_vk, new_delta); 39 | } 40 | UpdateData::StateAndDelta { state, delta } => { 41 | info!( 42 | "Received state and delta in UpdateNotification state: {:?} delta: {:?}", 43 | state, delta 44 | ); 45 | let new_state: ChatRoomStateV1 = from_cbor_slice::(&*state); 46 | room_synchronizer.update_room_state(&room_owner_vk, &new_state); 47 | } 48 | UpdateData::RelatedState { .. } => { 49 | warn!("Received related state update, ignored"); 50 | } 51 | UpdateData::RelatedDelta { .. } => { 52 | warn!("Received related delta update, ignored"); 53 | } 54 | UpdateData::RelatedStateAndDelta { .. } => { 55 | warn!("Received related state and delta update, ignored"); 56 | } 57 | } 58 | 59 | Ok(()) 60 | } 61 | -------------------------------------------------------------------------------- /ui/src/components/app/freenet_api/response_handler/update_response.rs: -------------------------------------------------------------------------------- 1 | use dioxus::logger::tracing::info; 2 | use freenet_stdlib::prelude::ContractKey; 3 | 4 | pub fn handle_update_response(key: ContractKey, summary: Vec) { 5 | let summary_len = summary.len(); 6 | info!( 7 | "Received update response for key {key}, summary length {summary_len}, currently ignored" 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/components/app/sync_info.rs: -------------------------------------------------------------------------------- 1 | use crate::components::app::ROOMS; 2 | use crate::util::owner_vk_to_contract_key; 3 | use dioxus::logger::tracing::debug; 4 | use dioxus::prelude::{Global, GlobalSignal}; 5 | use dioxus::signals::Readable; 6 | use ed25519_dalek::VerifyingKey; 7 | use freenet_stdlib::prelude::ContractInstanceId; 8 | use river_common::room_state::member::MemberId; 9 | use river_common::ChatRoomStateV1; 10 | use std::collections::HashMap; 11 | 12 | pub static SYNC_INFO: GlobalSignal = Global::new(|| SyncInfo::new()); 13 | 14 | pub struct SyncInfo { 15 | map: HashMap, 16 | instances: HashMap, 17 | } 18 | 19 | pub struct RoomSyncInfo { 20 | pub sync_status: RoomSyncStatus, 21 | // TODO: Would be better if state implemented Hash trait and just store 22 | // a hash of the state 23 | pub last_synced_state: Option, 24 | } 25 | 26 | impl SyncInfo { 27 | pub fn new() -> Self { 28 | SyncInfo { 29 | map: HashMap::new(), 30 | instances: HashMap::new(), 31 | } 32 | } 33 | 34 | pub fn register_new_room(&mut self, owner_key: VerifyingKey) { 35 | let contract_key = owner_vk_to_contract_key(&owner_key); 36 | let contract_id = contract_key.id(); 37 | 38 | if !self.map.contains_key(&owner_key) { 39 | debug!( 40 | "Registering new room with owner key: {:?}, contract ID: {}", 41 | MemberId::from(owner_key), 42 | contract_id 43 | ); 44 | 45 | self.map.insert( 46 | owner_key, 47 | RoomSyncInfo { 48 | sync_status: RoomSyncStatus::Disconnected, 49 | last_synced_state: None, 50 | }, 51 | ); 52 | 53 | self.instances.insert(*contract_id, owner_key); 54 | debug!( 55 | "Added mapping from contract ID {} to owner key {:?}", 56 | contract_id, 57 | MemberId::from(owner_key) 58 | ); 59 | } else { 60 | debug!( 61 | "Room with owner key {:?} already registered", 62 | MemberId::from(owner_key) 63 | ); 64 | } 65 | } 66 | 67 | pub fn update_sync_status(&mut self, owner_key: &VerifyingKey, status: RoomSyncStatus) { 68 | if let Some(sync_info) = self.map.get_mut(owner_key) { 69 | sync_info.sync_status = status; 70 | } 71 | } 72 | 73 | pub fn update_last_synced_state(&mut self, owner_key: &VerifyingKey, state: &ChatRoomStateV1) { 74 | if let Some(sync_info) = self.map.get_mut(owner_key) { 75 | sync_info.last_synced_state = Some(state.clone()); 76 | } 77 | } 78 | 79 | pub fn get_owner_vk_for_instance_id( 80 | &self, 81 | instance_id: &ContractInstanceId, 82 | ) -> Option { 83 | let result = self.instances.get(instance_id).copied(); 84 | if result.is_some() { 85 | debug!("Found owner key for contract ID {}", instance_id); 86 | } else { 87 | debug!("No owner key found for contract ID {}", instance_id); 88 | // Log all known mappings to help debug 89 | for (id, vk) in &self.instances { 90 | debug!( 91 | "Known mapping: contract ID {} -> owner key {:?}", 92 | id, 93 | MemberId::from(*vk) 94 | ); 95 | } 96 | } 97 | result 98 | } 99 | 100 | pub fn rooms_awaiting_subscription(&mut self) -> HashMap { 101 | let mut rooms_awaiting_subscription = HashMap::new(); 102 | let rooms = ROOMS.read(); 103 | 104 | for (key, room_data) in rooms.map.iter() { 105 | // Register new rooms automatically 106 | if !self.map.contains_key(key) { 107 | self.register_new_room(*key); 108 | self.update_last_synced_state(key, &room_data.room_state); 109 | } 110 | 111 | // Add room to awaiting list if it's disconnected 112 | if self.map.get(key).unwrap().sync_status == RoomSyncStatus::Disconnected { 113 | rooms_awaiting_subscription.insert(*key, room_data.room_state.clone()); 114 | } 115 | } 116 | 117 | rooms_awaiting_subscription 118 | } 119 | 120 | /// Returns a list of rooms for which an update should be sent to the network, 121 | /// automatically updates the last_synced_state for each room 122 | pub fn needs_to_send_update(&mut self) -> HashMap { 123 | let mut rooms_needing_update = HashMap::new(); 124 | let rooms = ROOMS.read(); 125 | 126 | debug!( 127 | "Checking for rooms that need updates, total rooms: {}", 128 | rooms.map.len() 129 | ); 130 | 131 | for (key, room_data) in rooms.map.iter() { 132 | // Register new rooms automatically 133 | if !self.map.contains_key(key) { 134 | debug!("Registering new room: {:?}", key); 135 | self.register_new_room(*key); 136 | } 137 | 138 | let sync_info = self.map.get(key).unwrap(); 139 | let sync_status = &sync_info.sync_status; 140 | let has_last_synced = sync_info.last_synced_state.is_some(); 141 | let states_match = sync_info.last_synced_state.as_ref() == Some(&room_data.room_state); 142 | 143 | debug!( 144 | "Room {:?} - sync status: {:?}, has last synced: {}, states match: {}", 145 | MemberId::from(key), 146 | sync_status, 147 | has_last_synced, 148 | states_match 149 | ); 150 | 151 | // Add detailed logging to understand why states match or don't match 152 | if let Some(last_state) = &sync_info.last_synced_state { 153 | debug!( 154 | "Last synced state members: {}", 155 | last_state.members.members.len() 156 | ); 157 | for member in &last_state.members.members { 158 | debug!(" Last synced member: {:?}", member.member.id()); 159 | } 160 | 161 | debug!( 162 | "Current state members: {}", 163 | room_data.room_state.members.members.len() 164 | ); 165 | for member in &room_data.room_state.members.members { 166 | debug!(" Current member: {:?}", member.member.id()); 167 | } 168 | 169 | // Also check member info 170 | debug!( 171 | "Last synced member info: {}", 172 | last_state.member_info.member_info.len() 173 | ); 174 | for info in &last_state.member_info.member_info { 175 | debug!( 176 | " Last synced member info: {:?}, version: {}", 177 | info.member_info.member_id, info.member_info.version 178 | ); 179 | } 180 | 181 | debug!( 182 | "Current member info: {}", 183 | room_data.room_state.member_info.member_info.len() 184 | ); 185 | for info in &room_data.room_state.member_info.member_info { 186 | debug!( 187 | " Current member info: {:?}, version: {}", 188 | info.member_info.member_id, info.member_info.version 189 | ); 190 | } 191 | } 192 | 193 | // Add room to update list if it's subscribed and the state has changed 194 | if *sync_status == RoomSyncStatus::Subscribed { 195 | if !states_match { 196 | debug!( 197 | "Room {:?} needs update - state has changed", 198 | MemberId::from(key) 199 | ); 200 | rooms_needing_update.insert(*key, room_data.room_state.clone()); 201 | // Don't update the last synced state here - it will be updated after successful network send 202 | } else { 203 | debug!( 204 | "Room {:?} doesn't need update - state unchanged", 205 | MemberId::from(key) 206 | ); 207 | } 208 | } else { 209 | debug!( 210 | "Room {:?} doesn't need update - not subscribed (status: {:?})", 211 | MemberId::from(key), 212 | sync_status 213 | ); 214 | } 215 | } 216 | 217 | debug!("Found {} rooms needing updates", rooms_needing_update.len()); 218 | rooms_needing_update 219 | } 220 | 221 | /// Register that the state's current value has been sent to the network 222 | pub fn state_updated(&mut self, owner_key: &VerifyingKey, new_state: ChatRoomStateV1) { 223 | if let Some(sync_info) = self.map.get_mut(owner_key) { 224 | sync_info.last_synced_state = Some(new_state); 225 | } 226 | } 227 | } 228 | 229 | #[derive(Clone, PartialEq, Debug)] 230 | pub enum RoomSyncStatus { 231 | Disconnected, 232 | 233 | Subscribing, 234 | 235 | Subscribed, 236 | 237 | Error(String), 238 | } 239 | -------------------------------------------------------------------------------- /ui/src/components/conversation/message_input.rs: -------------------------------------------------------------------------------- 1 | use dioxus::logger::tracing::*; 2 | use dioxus::prelude::*; 3 | 4 | #[component] 5 | pub fn MessageInput(new_message: Signal, handle_send_message: EventHandler<()>) -> Element { 6 | rsx! { 7 | div { class: "new-message", 8 | div { class: "field has-addons", 9 | div { class: "control is-expanded", 10 | input { 11 | class: "input", 12 | r#type: "text", 13 | placeholder: "Type your message...", 14 | value: "{new_message}", 15 | oninput: move |evt| new_message.set(evt.value().to_string()), 16 | onkeydown: move |evt| { 17 | // TODO: Shift+Enter shouldn't do a newline 18 | if evt.key() == Key::Enter { 19 | handle_send_message.call(()); 20 | } 21 | } 22 | } 23 | } 24 | div { class: "control", 25 | button { 26 | class: "button send-button", 27 | onclick: move |_| { 28 | info!("Send button clicked"); 29 | handle_send_message.call(()); 30 | }, 31 | "Send" 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ui/src/components/conversation/not_member_notification.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use ed25519_dalek::VerifyingKey; 3 | use river_common::crypto_values::CryptoValue; 4 | use wasm_bindgen::JsCast; 5 | use web_sys; 6 | 7 | #[component] 8 | pub fn NotMemberNotification(user_verifying_key: VerifyingKey) -> Element { 9 | let encoded_key = 10 | use_signal(|| CryptoValue::VerifyingKey(user_verifying_key.clone()).to_encoded_string()); 11 | let mut button_text = use_signal(|| "Copy".to_string()); 12 | 13 | let copy_to_clipboard = move |_| { 14 | if let Some(window) = web_sys::window() { 15 | if let Ok(navigator) = window.navigator().dyn_into::() { 16 | let clipboard = navigator.clipboard(); 17 | let _ = clipboard.write_text(&encoded_key.read()); 18 | button_text.set("Copied!".to_string()); 19 | } 20 | } 21 | }; 22 | 23 | rsx! { 24 | div { class: "box has-background-light border-left-warning", 25 | p { class: "mb-3", 26 | "You are not a member of this room. Share this key with a current member so they can invite you:" 27 | } 28 | p { class: "mb-2 has-text-weight-bold", "Your verifying key:" } 29 | div { class: "field has-addons", 30 | p { class: "control is-expanded", 31 | input { 32 | class: "input small-font-input", 33 | r#type: "text", 34 | value: "{encoded_key}", 35 | readonly: "true" 36 | } 37 | } 38 | p { class: "control", 39 | button { 40 | class: "button is-info copy-button", 41 | onclick: copy_to_clipboard, 42 | span { class: "icon", 43 | i { class: "fas fa-copy" } 44 | } 45 | span { 46 | "{button_text}" 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /ui/src/components/members/member_info_modal/ban_button.rs: -------------------------------------------------------------------------------- 1 | use crate::components::app::{CURRENT_ROOM, MEMBER_INFO_MODAL, ROOMS}; 2 | use crate::room_data::RoomData; 3 | use crate::util::get_current_system_time; 4 | use dioxus::logger::tracing::error; 5 | use dioxus::prelude::*; 6 | use freenet_scaffold::ComposableState; 7 | use river_common::room_state::ban::{AuthorizedUserBan, UserBan}; 8 | use river_common::room_state::member::MemberId; 9 | use river_common::room_state::{ChatRoomParametersV1, ChatRoomStateV1Delta}; 10 | 11 | #[component] 12 | pub fn BanButton(member_to_ban: MemberId, is_downstream: bool, nickname: String) -> Element { 13 | // Memos 14 | let current_room_data_signal: Memo> = use_memo(move || { 15 | CURRENT_ROOM 16 | .read() 17 | .owner_key 18 | .as_ref() 19 | .and_then(|key| ROOMS.read().map.get(key).cloned()) 20 | }); 21 | 22 | let mut show_confirmation = use_signal(|| false); 23 | 24 | let execute_ban = move |_| { 25 | if let (Some(current_room), Some(room_data)) = ( 26 | CURRENT_ROOM.read().owner_key, 27 | current_room_data_signal.read().as_ref(), 28 | ) { 29 | let user_signing_key = &room_data.self_sk; 30 | let ban = UserBan { 31 | owner_member_id: MemberId::from(¤t_room), 32 | banned_at: get_current_system_time(), 33 | banned_user: member_to_ban, 34 | }; 35 | 36 | let authorized_ban = AuthorizedUserBan::new( 37 | ban, 38 | MemberId::from(&user_signing_key.verifying_key()), 39 | user_signing_key, 40 | ); 41 | 42 | let delta = ChatRoomStateV1Delta { 43 | bans: Some(vec![authorized_ban]), 44 | ..Default::default() 45 | }; 46 | 47 | MEMBER_INFO_MODAL.with_mut(|modal| { 48 | modal.member = None; 49 | }); 50 | 51 | ROOMS.with_mut(|rooms| { 52 | if let Some(room_data_mut) = rooms.map.get_mut(¤t_room) { 53 | if let Err(e) = room_data_mut.room_state.apply_delta( 54 | &room_data.room_state, 55 | &ChatRoomParametersV1 { 56 | owner: current_room, 57 | }, 58 | &Some(delta), 59 | ) { 60 | error!("Failed to apply ban delta: {:?}", e); 61 | } 62 | } 63 | }); 64 | } 65 | }; 66 | 67 | if is_downstream { 68 | rsx! { 69 | div { 70 | button { 71 | class: "button is-danger mt-3", 72 | onclick: move |_| show_confirmation.set(true), 73 | "Ban User" 74 | } 75 | 76 | div { 77 | class: "modal", 78 | class: if *show_confirmation.read() { "is-active" } else { "" }, 79 | 80 | div { class: "modal-background" } 81 | 82 | div { class: "modal-card", 83 | header { class: "modal-card-head", 84 | p { class: "modal-card-title", "Confirm Ban" } 85 | button { 86 | class: "delete", 87 | onclick: move |_| show_confirmation.set(false), 88 | aria_label: "close" 89 | } 90 | } 91 | 92 | section { class: "modal-card-body", 93 | p { 94 | "Are you sure you want to ban " 95 | strong { "{nickname}" } 96 | " (ID: " 97 | code { "{member_to_ban}" } 98 | ")? This action cannot be undone." 99 | } 100 | } 101 | 102 | footer { class: "modal-card-foot", 103 | button { 104 | class: "button is-danger", 105 | onclick: execute_ban, 106 | "Yes, Ban User" 107 | } 108 | button { 109 | class: "button", 110 | onclick: move |_| show_confirmation.set(false), 111 | "Cancel" 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } else { 119 | rsx! { "" } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /ui/src/components/members/member_info_modal/invited_by_field.rs: -------------------------------------------------------------------------------- 1 | use crate::components::app::MEMBER_INFO_MODAL; 2 | use dioxus::prelude::*; 3 | use river_common::room_state::member::MemberId; 4 | 5 | #[component] 6 | pub fn InvitedByField(invited_by: String, inviter_id: Option) -> Element { 7 | rsx! { 8 | div { 9 | class: "field", 10 | label { class: "label is-medium", "Invited by" } 11 | div { 12 | class: "control", 13 | div { 14 | class: "input", 15 | style: "display: flex; align-items: center; height: auto; min-height: 2.5em;", 16 | { 17 | if inviter_id.is_some() { 18 | rsx! { 19 | span { 20 | class: "clickable-username", 21 | style: "cursor: pointer; display: inline-block;", 22 | onclick: move |_event| { 23 | MEMBER_INFO_MODAL.with_mut(|uim| { 24 | uim.member = inviter_id; 25 | }) 26 | }, 27 | "{invited_by}" 28 | } 29 | } 30 | } else { 31 | rsx! { 32 | span { 33 | "{invited_by}" 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ui/src/components/members/member_info_modal/nickname_field.rs: -------------------------------------------------------------------------------- 1 | use crate::components::app::{CURRENT_ROOM, ROOMS}; 2 | use dioxus::events::Key; 3 | use dioxus::logger::tracing::*; 4 | use dioxus::prelude::*; 5 | use freenet_scaffold::ComposableState; 6 | use river_common::room_state::member::MemberId; 7 | use river_common::room_state::member_info::{AuthorizedMemberInfo, MemberInfo}; 8 | use river_common::room_state::{ChatRoomParametersV1, ChatRoomStateV1Delta}; 9 | use std::rc::Rc; 10 | 11 | #[component] 12 | pub fn NicknameField(member_info: AuthorizedMemberInfo) -> Element { 13 | // Compute values 14 | let self_signing_key = { 15 | let current_room = CURRENT_ROOM.read(); 16 | if let Some(key) = current_room.owner_key.as_ref() { 17 | let rooms = ROOMS.read(); 18 | if let Some(room_data) = rooms.map.get(key) { 19 | Some(room_data.self_sk.clone()) 20 | } else { 21 | None 22 | } 23 | } else { 24 | None 25 | } 26 | }; 27 | 28 | let self_member_id = self_signing_key 29 | .as_ref() 30 | .map(|sk| MemberId::from(&sk.verifying_key())); 31 | 32 | let member_id = member_info.member_info.member_id; 33 | let is_self = self_member_id 34 | .as_ref() 35 | .map(|smi| smi == &member_id) 36 | .unwrap_or(false); 37 | 38 | let mut temp_nickname = use_signal(|| member_info.member_info.preferred_nickname.clone()); 39 | let mut input_element = use_signal(|| None as Option>); 40 | 41 | let save_changes = { 42 | info!("Saving nickname changes"); 43 | 44 | let self_signing_key = self_signing_key.clone(); 45 | let member_info = member_info.clone(); 46 | 47 | move |new_value: String| { 48 | if new_value.is_empty() { 49 | warn!("Nickname cannot be empty"); 50 | return; 51 | } 52 | 53 | // Clone new_value before moving it 54 | 55 | let delta = if let Some(signing_key) = self_signing_key.clone() { 56 | let new_member_info = MemberInfo { 57 | member_id: member_info.member_info.member_id.clone(), 58 | version: member_info.member_info.version + 1, 59 | preferred_nickname: new_value, 60 | }; 61 | let new_authorized_member_info = 62 | AuthorizedMemberInfo::new_with_member_key(new_member_info, &signing_key); 63 | Some(ChatRoomStateV1Delta { 64 | member_info: Some(vec![new_authorized_member_info]), 65 | ..Default::default() 66 | }) 67 | } else { 68 | warn!("No signing key available"); 69 | None 70 | }; 71 | 72 | if let Some(delta) = delta { 73 | info!("Saving changes to nickname with delta: {:?}", delta); 74 | 75 | // Get the owner key first 76 | let owner_key = CURRENT_ROOM.read().owner_key.clone(); 77 | 78 | if let Some(owner_key) = owner_key { 79 | // Use with_mut for atomic update 80 | ROOMS.with_mut(|rooms| { 81 | if let Some(room_data) = rooms.map.get_mut(&owner_key) { 82 | info!( 83 | "State before applying nickname delta: {:?}", 84 | room_data.room_state 85 | ); 86 | if let Err(e) = room_data.room_state.apply_delta( 87 | &room_data.room_state.clone(), 88 | &ChatRoomParametersV1 { owner: owner_key }, 89 | &Some(delta), 90 | ) { 91 | error!("Failed to apply delta: {:?}", e); 92 | } 93 | info!( 94 | "State after applying nickname delta: {:?}", 95 | room_data.room_state 96 | ); 97 | } else { 98 | warn!("Room state not found for current room"); 99 | } 100 | }); 101 | } 102 | } 103 | } 104 | }; 105 | 106 | let on_input = move |evt: dioxus_core::Event| { 107 | temp_nickname.set(evt.value().clone()); 108 | }; 109 | 110 | let on_blur = { 111 | let save_changes = save_changes.clone(); 112 | let temp_nickname = temp_nickname.clone(); 113 | move |_| { 114 | let new_value = temp_nickname(); 115 | save_changes(new_value); 116 | } 117 | }; 118 | 119 | let on_keydown = { 120 | let save_changes = save_changes.clone(); 121 | let temp_nickname = temp_nickname.clone(); 122 | move |evt: dioxus_core::Event| { 123 | if evt.key() == Key::Enter { 124 | let new_value = temp_nickname(); 125 | save_changes(new_value); 126 | 127 | // Blur the input element 128 | if let Some(element) = input_element() { 129 | wasm_bindgen_futures::spawn_local(async move { 130 | let _ = element.set_focus(false).await; 131 | }); 132 | } 133 | } 134 | } 135 | }; 136 | 137 | rsx! { 138 | div { 139 | class: "field", 140 | label { class: "label", "Nickname" } 141 | div { 142 | class: if is_self { "control has-icons-right" } else { "control" }, 143 | input { 144 | class: "input", 145 | value: "{temp_nickname}", 146 | readonly: !is_self, 147 | oninput: on_input, 148 | onblur: on_blur, 149 | onkeydown: on_keydown, 150 | onmounted: move |cx| input_element.set(Some(cx.data())), 151 | } 152 | if is_self { 153 | span { 154 | class: "icon is-right", 155 | i { 156 | class: "fa-solid fa-pencil" 157 | } 158 | } 159 | } 160 | } 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /ui/src/components/room_list.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod create_room_modal; 2 | pub(crate) mod edit_room_modal; 3 | pub(crate) mod receive_invitation_modal; 4 | pub(crate) mod room_name_field; 5 | 6 | use crate::components::app::{CREATE_ROOM_MODAL, CURRENT_ROOM, ROOMS}; 7 | use crate::room_data::CurrentRoom; 8 | use create_room_modal::CreateRoomModal; 9 | use dioxus::prelude::*; 10 | use dioxus_free_icons::{ 11 | icons::fa_solid_icons::{FaComments, FaLink, FaPlus}, 12 | Icon, 13 | }; 14 | use wasm_bindgen_futures::spawn_local; 15 | 16 | // Access the build timestamp (ISO 8601 format) environment variable set by build.rs 17 | const BUILD_TIMESTAMP_ISO: &str = env!("BUILD_TIMESTAMP_ISO", "Build timestamp not set"); 18 | 19 | #[component] 20 | pub fn RoomList() -> Element { 21 | rsx! { 22 | aside { class: "room-list", 23 | div { class: "logo-container", 24 | img { 25 | class: "logo", 26 | src: asset!("/assets/river_logo.svg"), 27 | alt: "River Logo" 28 | } 29 | } 30 | div { class: "sidebar-header", 31 | div { class: "rooms-title", 32 | h2 { 33 | Icon { 34 | width: 20, 35 | height: 20, 36 | icon: FaComments, 37 | } 38 | span { "Rooms" } 39 | } 40 | } 41 | } 42 | ul { class: "room-list-list", 43 | CreateRoomModal {} 44 | {ROOMS.read().map.iter().map(|(room_key, room_data)| { 45 | let room_key = *room_key; 46 | let room_name = room_data.room_state.configuration.configuration.name.clone(); 47 | let is_current = CURRENT_ROOM.read().owner_key == Some(room_key); 48 | rsx! { 49 | li { 50 | key: "{room_key:?}", 51 | class: if is_current { "chat-room-item active" } else { "chat-room-item" }, 52 | div { 53 | class: "room-name-button", 54 | onclick: move |_| { 55 | *CURRENT_ROOM.write() = CurrentRoom { owner_key : Some(room_key)}; 56 | }, 57 | div { 58 | class: "room-name-container", 59 | style: "min-width: 0; word-wrap: break-word; white-space: normal;", 60 | span { 61 | class: "room-name-text", 62 | style: "word-break: break-word;", 63 | "{room_name}" 64 | } 65 | } 66 | } 67 | } 68 | } 69 | }).collect::>().into_iter()} 70 | } 71 | div { class: "room-actions", 72 | { 73 | rsx! { 74 | button { 75 | class: "create", 76 | onclick: move |_| { 77 | CREATE_ROOM_MODAL.write().show = true; 78 | }, 79 | Icon { 80 | width: 16, 81 | height: 16, 82 | icon: FaPlus, 83 | } 84 | span { "Create Room" } 85 | } 86 | button { 87 | class: "add", 88 | disabled: true, 89 | Icon { 90 | width: 16, 91 | height: 16, 92 | icon: FaLink, 93 | } 94 | span { "Add Room" } 95 | } 96 | } 97 | } 98 | } 99 | 100 | // --- Add the build datetime information here --- 101 | div { 102 | class: "build-info", 103 | // Display the UTC build time directly 104 | {"Built: "} {BUILD_TIMESTAMP_ISO} {" (UTC)"} 105 | } 106 | // --- End of build datetime information --- 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /ui/src/components/room_list/create_room_modal.rs: -------------------------------------------------------------------------------- 1 | use crate::components::app::{CREATE_ROOM_MODAL, CURRENT_ROOM, ROOMS}; 2 | use dioxus::prelude::*; 3 | use ed25519_dalek::SigningKey; 4 | 5 | #[component] 6 | pub fn CreateRoomModal() -> Element { 7 | let mut room_name = use_signal(String::new); 8 | let mut nickname = use_signal(String::new); 9 | 10 | let create_room = move |_| { 11 | let name = room_name.read().clone(); 12 | if name.is_empty() { 13 | return; 14 | } 15 | 16 | // Generate key outside the borrow 17 | let self_sk = SigningKey::generate(&mut rand::thread_rng()); 18 | let nick = nickname.read().clone(); 19 | 20 | // Create room and get the key 21 | let new_room_key = 22 | ROOMS.with_mut(|rooms| rooms.create_new_room_with_name(self_sk, name, nick)); 23 | 24 | // Update current room 25 | CURRENT_ROOM.with_mut(|current_room| { 26 | current_room.owner_key = Some(new_room_key); 27 | }); 28 | 29 | // Reset and close modal 30 | room_name.set(String::new()); 31 | nickname.set(String::new()); 32 | CREATE_ROOM_MODAL.with_mut(|modal| { 33 | modal.show = false; 34 | }); 35 | }; 36 | 37 | rsx! { 38 | div { 39 | class: format_args!("modal {}", if CREATE_ROOM_MODAL.read().show { "is-active" } else { "" }), 40 | div { 41 | class: "modal-background", 42 | onclick: move |_| { 43 | CREATE_ROOM_MODAL.with_mut(|modal| { 44 | modal.show = false; 45 | }); 46 | } 47 | } 48 | div { 49 | class: "modal-content", 50 | div { 51 | class: "box", 52 | h1 { class: "title is-4 mb-3", "Create New Room" } 53 | 54 | div { class: "field", 55 | label { class: "label", "Room Name" } 56 | div { class: "control", 57 | input { 58 | class: "input", 59 | value: "{room_name}", 60 | onchange: move |evt| room_name.set(evt.value().to_string()) 61 | } 62 | } 63 | } 64 | 65 | div { class: "field", 66 | label { class: "label", "Your Nickname" } 67 | div { class: "control", 68 | input { 69 | class: "input", 70 | value: "{nickname}", 71 | onchange: move |evt| nickname.set(evt.value().to_string()) 72 | } 73 | } 74 | } 75 | 76 | div { class: "field", 77 | div { class: "control", 78 | button { 79 | class: "button is-primary", 80 | onclick: create_room, 81 | "Create Room" 82 | } 83 | } 84 | } 85 | } 86 | } 87 | button { 88 | class: "modal-close is-large", 89 | onclick: move |_| { 90 | CREATE_ROOM_MODAL.with_mut(|modal| { 91 | modal.show = false; 92 | }); 93 | } 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /ui/src/components/room_list/edit_room_modal.rs: -------------------------------------------------------------------------------- 1 | use super::room_name_field::RoomNameField; 2 | use crate::components::app::{CURRENT_ROOM, EDIT_ROOM_MODAL, ROOMS}; 3 | use dioxus::prelude::*; 4 | use std::ops::Deref; 5 | 6 | #[component] 7 | pub fn EditRoomModal() -> Element { 8 | // State for leave confirmation 9 | let mut show_leave_confirmation = use_signal(|| false); 10 | 11 | // Memoize the room being edited 12 | let editing_room = use_memo(move || { 13 | EDIT_ROOM_MODAL.read().room.and_then(|editing_room_vk| { 14 | ROOMS.read().map.iter().find_map(|(room_vk, room_data)| { 15 | if &editing_room_vk == room_vk { 16 | Some(room_data.clone()) 17 | } else { 18 | None 19 | } 20 | }) 21 | }) 22 | }); 23 | 24 | // Memoize the room configuration 25 | let room_config = use_memo(move || { 26 | editing_room 27 | .read() 28 | .as_ref() 29 | .map(|room_data| room_data.room_state.configuration.configuration.clone()) 30 | }); 31 | 32 | // Memoize if the current user is the owner of the room being edited 33 | let user_is_owner = use_memo(move || { 34 | editing_room.read().as_ref().map_or(false, |room_data| { 35 | let user_vk = room_data.self_sk.verifying_key(); 36 | let room_vk = EDIT_ROOM_MODAL.read().room.unwrap(); 37 | user_vk == room_vk 38 | }) 39 | }); 40 | 41 | // Render the modal if room configuration is available 42 | if let Some(config) = room_config.clone().read().deref() { 43 | rsx! { 44 | div { 45 | class: "modal is-active", 46 | div { 47 | class: "modal-background", 48 | onclick: move |_| { 49 | EDIT_ROOM_MODAL.write().room = None; 50 | } 51 | } 52 | div { 53 | class: "modal-content", 54 | div { 55 | class: "box", 56 | h1 { class: "title is-4 mb-3", "Room Configuration" } 57 | 58 | RoomNameField { 59 | config: config.clone(), 60 | is_owner: *user_is_owner.read() 61 | } 62 | 63 | // Leave Room Section 64 | if *show_leave_confirmation.read() { 65 | div { 66 | class: "notification is-warning mt-4", 67 | p { 68 | if *user_is_owner.read() { 69 | "Warning: You are the owner of this room. Leaving will permanently delete it for you. Other members might retain access if they have the contract key, but coordination will be lost." 70 | } else { 71 | "Are you sure you want to leave this room? This action cannot be undone." 72 | } 73 | } 74 | div { 75 | class: "buttons mt-3", 76 | button { 77 | class: "button is-danger", 78 | onclick: move |_| { 79 | // Read the room_vk first and drop the read borrow 80 | let room_vk_to_remove = EDIT_ROOM_MODAL.read().room; 81 | 82 | if let Some(room_vk) = room_vk_to_remove { 83 | // Perform writes *after* the read borrow is dropped 84 | ROOMS.write().map.remove(&room_vk); 85 | 86 | // Check and potentially clear CURRENT_ROOM 87 | if CURRENT_ROOM.read().owner_key == Some(room_vk) { 88 | CURRENT_ROOM.write().owner_key = None; 89 | } 90 | 91 | // Close the modal *last* 92 | EDIT_ROOM_MODAL.write().room = None; 93 | } 94 | // Reset confirmation state regardless 95 | show_leave_confirmation.set(false); 96 | }, 97 | "Confirm Leave" 98 | } 99 | button { 100 | class: "button", 101 | onclick: move |_| show_leave_confirmation.set(false), 102 | "Cancel" 103 | } 104 | } 105 | } 106 | } else { 107 | // Only show Leave button if not confirming 108 | div { 109 | class: "field mt-4", 110 | div { 111 | class: "control", 112 | button { 113 | class: "button is-danger is-outlined", 114 | onclick: move |_| show_leave_confirmation.set(true), 115 | "Leave Room" 116 | } 117 | } 118 | } 119 | } 120 | } 121 | } 122 | button { 123 | class: "modal-close is-large", 124 | onclick: move |_| { 125 | EDIT_ROOM_MODAL.write().room = None; 126 | } 127 | } 128 | } 129 | } 130 | } else { 131 | rsx! {} 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /ui/src/components/room_list/room_name_field.rs: -------------------------------------------------------------------------------- 1 | use crate::components::app::{CURRENT_ROOM, ROOMS}; 2 | use dioxus::logger::tracing::*; 3 | use dioxus::prelude::*; 4 | use dioxus_core::Event; 5 | use freenet_scaffold::ComposableState; 6 | use river_common::room_state::configuration::{AuthorizedConfigurationV1, Configuration}; 7 | use river_common::room_state::{ChatRoomParametersV1, ChatRoomStateV1Delta}; 8 | 9 | #[component] 10 | pub fn RoomNameField(config: Configuration, is_owner: bool) -> Element { 11 | let mut room_name = use_signal(|| config.name.clone()); 12 | 13 | let update_room_name = move |evt: Event| { 14 | if !is_owner { 15 | return; 16 | } 17 | 18 | info!("Updating room name"); 19 | let new_name = evt.value().to_string(); 20 | if !new_name.is_empty() { 21 | room_name.set(new_name.clone()); 22 | let mut new_config = config.clone(); 23 | new_config.name = new_name; 24 | new_config.configuration_version += 1; 25 | 26 | // Get the owner key first 27 | let owner_key = CURRENT_ROOM.read().owner_key.expect("No owner key"); 28 | 29 | // Prepare the delta outside the borrow 30 | let delta = ROOMS.with(|rooms| { 31 | if let Some(room_data) = rooms.map.get(&owner_key) { 32 | let signing_key = &room_data.self_sk; 33 | let new_authorized_config = 34 | AuthorizedConfigurationV1::new(new_config, signing_key); 35 | 36 | Some(ChatRoomStateV1Delta { 37 | configuration: Some(new_authorized_config), 38 | ..Default::default() 39 | }) 40 | } else { 41 | error!("Room state not found for current room"); 42 | None 43 | } 44 | }); 45 | 46 | // Apply the delta if we have one 47 | if let Some(delta) = delta { 48 | ROOMS.with_mut(|rooms| { 49 | if let Some(room_data) = rooms.map.get_mut(&owner_key) { 50 | info!("Applying delta to room state"); 51 | let parent_state = room_data.room_state.clone(); 52 | match ComposableState::apply_delta( 53 | &mut room_data.room_state, 54 | &parent_state, 55 | &ChatRoomParametersV1 { owner: owner_key }, 56 | &Some(delta), 57 | ) { 58 | Ok(_) => info!("Delta applied successfully"), 59 | Err(e) => error!("Failed to apply delta: {:?}", e), 60 | } 61 | } 62 | }); 63 | } 64 | } else { 65 | error!("Room name is empty"); 66 | } 67 | }; 68 | 69 | rsx! { 70 | div { class: "field", 71 | label { class: "label", "Room Name" } 72 | div { class: "control", 73 | input { 74 | class: "input", 75 | value: "{room_name}", 76 | readonly: !is_owner, 77 | onchange: update_room_name, 78 | } 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /ui/src/constants.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | pub const KEY_VERSION_PREFIX: &str = "river:v1"; 4 | 5 | pub const ROOM_CONTRACT_WASM: &[u8] = 6 | include_bytes!("../../target/wasm32-unknown-unknown/release/room_contract.wasm"); 7 | 8 | pub const CHAT_DELEGATE_WASM: &[u8] = 9 | include_bytes!("../../target/wasm32-unknown-unknown/release/chat_delegate.wasm"); 10 | 11 | // pub const ROOM_CONTRACT_CODE_HASH: CodeHash = CodeHash::from_code(ROOM_CONTRACT_WASM); 12 | -------------------------------------------------------------------------------- /ui/src/invites.rs: -------------------------------------------------------------------------------- 1 | //! Handles pending room invitations and join requests 2 | //! 3 | //! This module manages the state of room invitations that are in the process 4 | //! of being accepted or retrieved. 5 | 6 | use ed25519_dalek::{SigningKey, VerifyingKey}; 7 | use river_common::room_state::member::AuthorizedMember; 8 | use std::collections::HashMap; 9 | 10 | /// Collection of pending room join requests 11 | #[derive(Clone, Debug, Default)] 12 | pub struct PendingInvites { 13 | /// Map of room owner keys to pending join information 14 | pub map: HashMap, // TODO: Make this private and use methods to access 15 | } 16 | 17 | impl PendingInvites { 18 | /// Creates a new instance of `PendingInvites` 19 | pub fn new() -> Self { 20 | Self { 21 | map: HashMap::new(), 22 | } 23 | } 24 | /* 25 | /// Adds a new pending room join request 26 | pub fn add(&mut self, owner_key: VerifyingKey, join_info: PendingRoomJoin) { 27 | self.map.insert(owner_key, join_info); 28 | } 29 | 30 | /// Removes a pending room join request 31 | pub fn remove(&mut self, owner_key: &VerifyingKey) { 32 | self.map.remove(owner_key); 33 | } 34 | */ 35 | } 36 | 37 | /// Information about a pending room join 38 | #[derive(Clone, Debug)] 39 | pub struct PendingRoomJoin { 40 | /// The authorized member data for the join 41 | pub authorized_member: AuthorizedMember, 42 | /// The signing key for the invited member 43 | pub invitee_signing_key: SigningKey, 44 | /// User's preferred nickname for this room 45 | pub preferred_nickname: String, 46 | /// Current status of the join request 47 | pub status: PendingRoomStatus, 48 | } 49 | 50 | /// Status of a pending room join request 51 | #[derive(Clone, Debug, PartialEq)] 52 | pub enum PendingRoomStatus { 53 | /// Ready to subscribe to room data 54 | PendingSubscription, 55 | /// Subscription request sent, waiting for response 56 | Subscribing, 57 | /// Successfully subscribed and retrieved room data 58 | Subscribed, 59 | /// Error occurred during subscription or retrieval 60 | Error(String), 61 | } 62 | -------------------------------------------------------------------------------- /ui/src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | 3 | use dioxus::prelude::*; 4 | 5 | mod components; 6 | mod constants; 7 | #[cfg(feature = "example-data")] 8 | mod example_data; 9 | mod invites; 10 | mod room_data; 11 | mod util; 12 | 13 | use components::app::App; 14 | 15 | // Custom implementation for getrandom when targeting wasm32-unknown-unknown 16 | #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 17 | #[no_mangle] 18 | unsafe extern "Rust" fn __getrandom_v02_custom( 19 | dest: *mut u8, 20 | len: usize, 21 | ) -> Result<(), getrandom::Error> { 22 | use std::num::NonZeroU32; 23 | use web_sys::window; 24 | 25 | // Get the window object 26 | let window = window().ok_or_else(|| getrandom::Error::from(NonZeroU32::new(1).unwrap()))?; 27 | 28 | // Get the crypto object directly from window 29 | let crypto = window 30 | .crypto() 31 | .map_err(|_| getrandom::Error::from(NonZeroU32::new(1).unwrap()))?; 32 | 33 | // Create a buffer to hold the random bytes 34 | let mut buffer = vec![0u8; len]; 35 | 36 | // Fill the buffer with random values 37 | match crypto.get_random_values_with_u8_array(&mut buffer) { 38 | Ok(_) => { 39 | // Copy the random bytes to the destination buffer 40 | let dest_slice = core::slice::from_raw_parts_mut(dest, len); 41 | dest_slice.copy_from_slice(&buffer); 42 | Ok(()) 43 | } 44 | Err(_) => Err(getrandom::Error::from(NonZeroU32::new(1).unwrap())), 45 | } 46 | } 47 | 48 | fn main() { 49 | dioxus::logger::initialize_default(); 50 | 51 | launch(App); 52 | } 53 | -------------------------------------------------------------------------------- /ui/src/pending_invites.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use ed25519_dalek::VerifyingKey; 3 | use river_common::room_state::member::AuthorizedMember; 4 | use std::collections::HashMap; 5 | 6 | #[derive(Clone)] 7 | pub struct PendingRoomJoin { 8 | pub authorized_member: AuthorizedMember, 9 | pub preferred_nickname: String, 10 | pub status: PendingRoomStatus, 11 | } 12 | 13 | #[derive(Clone, PartialEq)] 14 | pub enum PendingRoomStatus { 15 | Retrieving, 16 | Retrieved, 17 | Error(String), 18 | } 19 | 20 | #[derive(Clone)] 21 | pub struct PendingInvites { 22 | pub map: HashMap 23 | } 24 | 25 | impl Default for PendingInvites { 26 | fn default() -> Self { 27 | Self { 28 | map: HashMap::new() 29 | } 30 | } 31 | } 32 | 33 | // Global signal for pending invites 34 | pub static PENDING_INVITES: GlobalSignal = Global::new(PendingInvites::default); 35 | -------------------------------------------------------------------------------- /ui/src/room_data.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use crate::{constants::ROOM_CONTRACT_WASM, util::to_cbor_vec}; 4 | use ed25519_dalek::{SigningKey, VerifyingKey}; 5 | use freenet_scaffold::ComposableState; 6 | use freenet_stdlib::prelude::{ContractCode, ContractInstanceId, ContractKey, Parameters}; 7 | use river_common::room_state::configuration::{AuthorizedConfigurationV1, Configuration}; 8 | use river_common::room_state::member::AuthorizedMember; 9 | use river_common::room_state::member::MemberId; 10 | use river_common::room_state::member_info::{AuthorizedMemberInfo, MemberInfo}; 11 | use river_common::room_state::ChatRoomParametersV1; 12 | use river_common::ChatRoomStateV1; 13 | use serde::{Deserialize, Serialize}; 14 | use std::collections::HashMap; 15 | 16 | #[derive(Debug, PartialEq)] 17 | pub enum SendMessageError { 18 | UserNotMember, 19 | UserBanned, 20 | } 21 | 22 | #[derive(Clone, PartialEq, Serialize, Deserialize)] 23 | pub struct RoomData { 24 | pub owner_vk: VerifyingKey, 25 | pub room_state: ChatRoomStateV1, 26 | pub self_sk: SigningKey, 27 | pub contract_key: ContractKey, 28 | } 29 | 30 | impl RoomData { 31 | /// Check if the user can send a message in the room 32 | pub fn can_send_message(&self) -> Result<(), SendMessageError> { 33 | let verifying_key = self.self_sk.verifying_key(); 34 | // Must be owner or a member of the room to send a message 35 | if verifying_key == self.owner_vk 36 | || self 37 | .room_state 38 | .members 39 | .members 40 | .iter() 41 | .any(|m| m.member.member_vk == verifying_key) 42 | { 43 | // Must not be banned from the room to send a message 44 | if self 45 | .room_state 46 | .bans 47 | .0 48 | .iter() 49 | .any(|b| b.ban.banned_user == verifying_key.into()) 50 | { 51 | Err(SendMessageError::UserBanned) 52 | } else { 53 | Ok(()) 54 | } 55 | } else { 56 | Err(SendMessageError::UserNotMember) 57 | } 58 | } 59 | 60 | pub fn owner_id(&self) -> MemberId { 61 | self.owner_vk.into() 62 | } 63 | 64 | /// Replace an existing member entry with a new authorized member 65 | /// Returns true if the member was found and updated 66 | pub fn restore_member_access( 67 | &mut self, 68 | old_member_vk: VerifyingKey, 69 | new_authorized_member: AuthorizedMember, 70 | ) -> bool { 71 | // Find and replace the member entry 72 | if let Some(member) = self 73 | .room_state 74 | .members 75 | .members 76 | .iter_mut() 77 | .find(|m| m.member.member_vk == old_member_vk) 78 | { 79 | *member = new_authorized_member; 80 | true 81 | } else { 82 | false 83 | } 84 | } 85 | 86 | pub fn parameters(&self) -> ChatRoomParametersV1 { 87 | ChatRoomParametersV1 { 88 | owner: self.owner_vk, 89 | } 90 | } 91 | } 92 | 93 | pub struct CurrentRoom { 94 | pub owner_key: Option, 95 | } 96 | 97 | impl CurrentRoom { 98 | pub fn owner_id(&self) -> Option { 99 | self.owner_key.map(|vk| vk.into()) 100 | } 101 | 102 | pub fn owner_key(&self) -> Option<&VerifyingKey> { 103 | self.owner_key.as_ref() 104 | } 105 | } 106 | 107 | impl PartialEq for CurrentRoom { 108 | fn eq(&self, other: &Self) -> bool { 109 | self.owner_key == other.owner_key 110 | } 111 | } 112 | 113 | #[derive(Clone, Serialize, Deserialize)] 114 | pub struct Rooms { 115 | pub map: HashMap, 116 | } 117 | 118 | impl PartialEq for Rooms { 119 | fn eq(&self, other: &Self) -> bool { 120 | self.map == other.map 121 | } 122 | } 123 | 124 | impl Rooms { 125 | pub fn create_new_room_with_name( 126 | &mut self, 127 | self_sk: SigningKey, 128 | name: String, 129 | nickname: String, 130 | ) -> VerifyingKey { 131 | let owner_vk = self_sk.verifying_key(); 132 | let mut room_state = ChatRoomStateV1::default(); 133 | 134 | // Set initial configuration 135 | let mut config = Configuration::default(); 136 | config.name = name; 137 | config.owner_member_id = owner_vk.into(); 138 | room_state.configuration = AuthorizedConfigurationV1::new(config, &self_sk); 139 | 140 | // Add owner to member_info 141 | let owner_info = MemberInfo { 142 | member_id: owner_vk.into(), 143 | version: 0, 144 | preferred_nickname: nickname, 145 | }; 146 | let authorized_owner_info = AuthorizedMemberInfo::new(owner_info, &self_sk); 147 | room_state 148 | .member_info 149 | .member_info 150 | .push(authorized_owner_info); 151 | 152 | // Generate contract key for the room 153 | let parameters = ChatRoomParametersV1 { owner: owner_vk }; 154 | let params_bytes = to_cbor_vec(¶meters); 155 | let contract_code = ContractCode::from(ROOM_CONTRACT_WASM); 156 | let instance_id = 157 | ContractInstanceId::from_params_and_code(Parameters::from(params_bytes), contract_code); 158 | let contract_key = ContractKey::from(instance_id); 159 | 160 | let room_data = RoomData { 161 | owner_vk, 162 | room_state, 163 | self_sk, 164 | contract_key, 165 | }; 166 | 167 | self.map.insert(owner_vk, room_data); 168 | owner_vk 169 | } 170 | 171 | /// Merge the other Rooms into this Rooms (eg. when Rooms are loaded from storage) 172 | pub fn merge(&mut self, other: Rooms) -> Result<(), String> { 173 | for (vk, room_data) in other.map { 174 | // If not already in the map, add the room 175 | if !self.map.contains_key(&vk) { 176 | self.map.insert(vk, room_data); 177 | } else { 178 | // If the room is already in the map, merge in the new data 179 | let self_room_data = self.map.get_mut(&vk).unwrap(); 180 | if self_room_data.self_sk != room_data.self_sk { 181 | return Err("self_sk is different".to_string()); 182 | } 183 | self_room_data.room_state.merge( 184 | &self_room_data.room_state.clone(), 185 | &ChatRoomParametersV1 { owner: vk.clone() }, 186 | &room_data.room_state, 187 | )?; 188 | } 189 | } 190 | Ok(()) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /ui/src/util.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | mod ecies; 4 | 5 | use ed25519_dalek::VerifyingKey; 6 | use freenet_stdlib::prelude::{ContractCode, ContractInstanceId, ContractKey, Parameters}; 7 | use std::time::*; 8 | #[cfg(target_arch = "wasm32")] 9 | use wasm_bindgen::prelude::*; 10 | 11 | #[cfg(target_arch = "wasm32")] 12 | #[wasm_bindgen(inline_js = " 13 | export function get_current_time() { 14 | return Date.now(); 15 | } 16 | ")] 17 | extern "C" { 18 | fn get_current_time() -> f64; 19 | } 20 | 21 | pub fn get_current_system_time() -> SystemTime { 22 | #[cfg(target_arch = "wasm32")] 23 | { 24 | // Convert milliseconds since epoch to a Duration 25 | let millis = get_current_time(); 26 | let duration_since_epoch = Duration::from_millis(millis as u64); 27 | UNIX_EPOCH + duration_since_epoch 28 | } 29 | 30 | #[cfg(not(target_arch = "wasm32"))] 31 | { 32 | SystemTime::now() 33 | } 34 | } 35 | 36 | // Helper function to create a Duration from seconds 37 | pub fn seconds(s: u64) -> Duration { 38 | Duration::from_secs(s) 39 | } 40 | 41 | // Helper function to create a Duration from milliseconds 42 | pub fn millis(ms: u64) -> Duration { 43 | Duration::from_millis(ms) 44 | } 45 | 46 | /// A WASM-compatible sleep function that works in both browser and native environments 47 | pub async fn sleep(duration: Duration) { 48 | #[cfg(target_arch = "wasm32")] 49 | { 50 | let promise = js_sys::Promise::new(&mut |resolve, _| { 51 | let window = web_sys::window().unwrap(); 52 | let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( 53 | &resolve, 54 | duration.as_millis() as i32, 55 | ); 56 | }); 57 | let _ = wasm_bindgen_futures::JsFuture::from(promise).await; 58 | } 59 | 60 | #[cfg(not(target_arch = "wasm32"))] 61 | { 62 | // Use futures_timer for non-WASM environments to maintain compatibility 63 | let _ = futures_timer::Delay::new(duration).await; 64 | } 65 | } 66 | 67 | #[cfg(feature = "example-data")] 68 | mod name_gen; 69 | #[cfg(feature = "example-data")] 70 | pub use name_gen::random_full_name; 71 | 72 | use crate::constants::ROOM_CONTRACT_WASM; 73 | use river_common::room_state::ChatRoomParametersV1; 74 | 75 | pub fn to_cbor_vec(value: &T) -> Vec { 76 | let mut buffer = Vec::new(); 77 | ciborium::ser::into_writer(value, &mut buffer).unwrap(); 78 | buffer 79 | } 80 | 81 | pub fn from_cbor_slice(data: &[u8]) -> T { 82 | ciborium::de::from_reader(data).unwrap() 83 | } 84 | 85 | pub fn owner_vk_to_contract_key(owner_vk: &VerifyingKey) -> ContractKey { 86 | let params = ChatRoomParametersV1 { owner: *owner_vk }; 87 | let params_bytes = to_cbor_vec(¶ms); 88 | let parameters = Parameters::from(params_bytes); 89 | let contract_code = ContractCode::from(ROOM_CONTRACT_WASM); 90 | let instance_id = ContractInstanceId::from_params_and_code(parameters, contract_code); 91 | ContractKey::from(instance_id) 92 | } 93 | -------------------------------------------------------------------------------- /ui/src/util/ecies.rs: -------------------------------------------------------------------------------- 1 | use aes_gcm::{ 2 | aead::{Aead, KeyInit}, 3 | Aes256Gcm, Nonce, 4 | }; 5 | use curve25519_dalek::edwards::CompressedEdwardsY; 6 | use ed25519_dalek::{SigningKey, VerifyingKey}; 7 | use rand::rngs::OsRng; 8 | use sha2::{Digest, Sha256, Sha512}; 9 | use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519EphemeralSecret}; 10 | 11 | /// Encrypts a plaintext message using ECIES (Elliptic Curve Integrated Encryption Scheme). 12 | /// Uses ed25519_dalek SigningKey and VerifyingKey because they're used elsewhere in the codebase. 13 | /// 14 | /// # Arguments 15 | /// 16 | /// * `recipient_public_key` - The public key of the message recipient. 17 | /// * `plaintext` - The message to be encrypted. 18 | /// 19 | /// # Returns 20 | /// 21 | /// A tuple containing: 22 | /// * The encrypted ciphertext. 23 | /// * A 12-byte nonce used for encryption. 24 | /// * The ephemeral public key of the sender. 25 | #[allow(dead_code)] 26 | pub fn encrypt( 27 | recipient_public_key: &VerifyingKey, 28 | plaintext: &[u8], 29 | ) -> (Vec, [u8; 12], X25519PublicKey) { 30 | // Generate an ephemeral keypair 31 | let sender_private_key = X25519EphemeralSecret::random_from_rng(OsRng); 32 | let sender_public_key = X25519PublicKey::from(&sender_private_key); 33 | 34 | // Convert Ed25519 verifying key to X25519 public key 35 | let recipient_x25519_public_key = ed25519_to_x25519_public_key(recipient_public_key); 36 | 37 | // Derive shared secret using sender's private key and recipient's public key 38 | let shared_secret = sender_private_key.diffie_hellman(&recipient_x25519_public_key); 39 | 40 | // Use the shared secret to derive a symmetric key 41 | let symmetric_key = Sha256::digest(shared_secret.as_bytes()); 42 | 43 | // Generate a random nonce 44 | let nonce = rand::random::<[u8; 12]>(); 45 | 46 | // Encrypt the plaintext using AES-GCM 47 | let cipher = Aes256Gcm::new_from_slice(&symmetric_key).expect("Failed to create cipher"); 48 | let ciphertext = cipher 49 | .encrypt(&Nonce::from(nonce), plaintext) 50 | .expect("encryption failure!"); 51 | 52 | (ciphertext, nonce, sender_public_key) 53 | } 54 | 55 | #[allow(dead_code)] 56 | fn ed25519_to_x25519_public_key(ed25519_pk: &VerifyingKey) -> X25519PublicKey { 57 | let ed_y = CompressedEdwardsY(ed25519_pk.to_bytes()) 58 | .decompress() 59 | .expect("Invalid Edwards point"); 60 | let mont_u = ed_y.to_montgomery().to_bytes(); 61 | X25519PublicKey::from(mont_u) 62 | } 63 | 64 | /// Decrypts a ciphertext message using ECIES (Elliptic Curve Integrated Encryption Scheme). 65 | /// 66 | /// # Arguments 67 | /// 68 | /// * `recipient_private_key` - The private key of the message recipient. 69 | /// * `sender_public_key` - The ephemeral public key of the sender. 70 | /// * `ciphertext` - The encrypted message to be decrypted. 71 | /// * `nonce` - The 12-byte nonce used for encryption. 72 | /// 73 | /// # Returns 74 | /// 75 | /// The decrypted plaintext message as a vector of bytes. 76 | #[allow(dead_code)] 77 | pub fn decrypt( 78 | recipient_private_key: &SigningKey, 79 | sender_public_key: &X25519PublicKey, 80 | ciphertext: &[u8], 81 | nonce: &[u8; 12], 82 | ) -> Vec { 83 | // Convert Ed25519 signing key to X25519 private key 84 | let recipient_x25519_private_key = ed25519_to_x25519_private_key(recipient_private_key); 85 | 86 | // Derive shared secret using recipient's private key and sender's public key 87 | let shared_secret = recipient_x25519_private_key.diffie_hellman(sender_public_key); 88 | 89 | // Use the shared secret to derive the symmetric key 90 | let symmetric_key = Sha256::digest(shared_secret.as_bytes()); 91 | 92 | // Decrypt the ciphertext using AES-GCM 93 | let cipher = Aes256Gcm::new_from_slice(&symmetric_key).expect("Failed to create cipher"); 94 | let decrypted_message = cipher 95 | .decrypt(&Nonce::from(*nonce), ciphertext.as_ref()) 96 | .expect("decryption failure!"); 97 | 98 | decrypted_message 99 | } 100 | 101 | #[allow(dead_code)] 102 | fn ed25519_to_x25519_private_key(ed25519_sk: &SigningKey) -> X25519EphemeralSecret { 103 | let h = Sha512::digest(ed25519_sk.to_bytes()); 104 | let mut key = [0u8; 32]; 105 | key.copy_from_slice(&h[..32]); 106 | key[0] &= 248; 107 | key[31] &= 127; 108 | key[31] |= 64; 109 | X25519EphemeralSecret::from(key) 110 | } 111 | 112 | #[cfg(test)] 113 | mod tests { 114 | use super::*; 115 | use ed25519_dalek::{SigningKey, VerifyingKey}; 116 | 117 | #[test] 118 | fn test_ecies_encryption_decryption() { 119 | let mut rng = OsRng; 120 | 121 | // Generate recipient's Ed25519 keypair 122 | let recipient_private_key = SigningKey::generate(&mut rng); 123 | let recipient_public_key: VerifyingKey = VerifyingKey::from(&recipient_private_key); 124 | 125 | // Encrypt the message 126 | let plaintext = b"Secret message"; 127 | let (ciphertext, nonce, sender_public_key) = encrypt(&recipient_public_key, plaintext); 128 | 129 | // Decrypt the message 130 | let decrypted_message = decrypt( 131 | &recipient_private_key, 132 | &sender_public_key, 133 | &ciphertext, 134 | &nonce, 135 | ); 136 | 137 | // Ensure the decrypted message matches the original 138 | assert_eq!(decrypted_message, plaintext); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /ui/src/util/name_gen.rs: -------------------------------------------------------------------------------- 1 | use rand::seq::SliceRandom; 2 | 3 | static FIRST_NAMES: &[&str] = &[ 4 | "Alice", "Bob", "Charlie", "Diana", "Eve", "Ali", "Frank", "Grace", "Hannah", "Ivan", "Jack", 5 | "Kyle", "Karen", "Liam", "Mona", "Nate", "Olivia", "Paul", "Quinn", "Rachel", "Sam", "Tina", 6 | "Derek", "Uma", "Victor", "Wendy", "Xander", "Yara", "Zane", "Amy", "Ben", "Cleo", "Derek", 7 | "Ian", "Elena", "Finn", "Gina", "Harry", "Isla", "Seth", "Jon", "Kara", "Leo", "Mia", "Noah", 8 | "Nacho", 9 | ]; 10 | 11 | static LAST_NAMES: &[&str] = &[ 12 | "Smith", 13 | "Johnson", 14 | "Williams", 15 | "Brown", 16 | "Jones", 17 | "Golden", 18 | "Garcia", 19 | "Miller", 20 | "Davis", 21 | "Rodriguez", 22 | "Martinez", 23 | "Hernandez", 24 | "Lopez", 25 | "Gonzalez", 26 | "Wilson", 27 | "Anderson", 28 | "Thomas", 29 | "Taylor", 30 | "Moore", 31 | "Jackson", 32 | "Martin", 33 | "Clarke", 34 | "Meier", 35 | ]; 36 | 37 | pub fn random_full_name() -> String { 38 | let mut rng = rand::thread_rng(); 39 | let first = FIRST_NAMES.choose(&mut rng).unwrap(); 40 | let last = LAST_NAMES.choose(&mut rng).unwrap(); 41 | format!("{} {}", first, last) 42 | } 43 | --------------------------------------------------------------------------------