diff --git a/.gitignore b/.gitignore index ea8c4bf..903cc36 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ /target +.claude +/web-ui/target +/web-ui/node_modules +/web-ui/.env +/web-ui/.env.local +/web-ui/dist diff --git a/Cargo.lock b/Cargo.lock index 7a0c5d9..c3f925a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -530,18 +530,28 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 8b2a424..43cce22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,7 @@ tokio = { version = "1.0", features = ["sync"] } serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" anyhow = "1.0.97" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +async-tungstenite = { version = "0.28", features = ["async-std-runtime"] } +futures-util = "0.3" diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..de2e426 --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,186 @@ +# ๐ŸŽจ Feature Request: Beautiful Web UI with Yew Framework + +## ๐Ÿ“‹ Summary +Transform the current CLI-based async-chat into a modern, beautiful web application using Yew (Rust + WebAssembly) for the frontend while keeping the existing Rust server backend. + +## ๐ŸŽฏ Motivation +- **Better User Experience**: Move from CLI to intuitive web interface +- **Full Rust Stack**: Showcase Rust's capabilities for both frontend and backend +- **Modern Chat Experience**: WhatsApp/Discord-like interface +- **Learning Opportunity**: Demonstrate Yew + WebAssembly in a real project +- **Community Showcase**: Highlight Rust-Cameroon's technical capabilities + +## ๐Ÿš€ Proposed Solution + +### **Tech Stack** +- **Frontend**: Yew + WebAssembly (WASM) +- **Backend**: Keep existing async-std Rust server +- **Communication**: WebSocket for real-time messaging +- **Styling**: TailwindCSS + CSS modules +- **Build Tool**: Trunk for WASM bundling +- **State Management**: Yewdux or built-in Yew state +- **Icons**: Lucide icons or similar +- **WebSocket Client**: gloo-net for browser APIs + +### **Architecture** +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” WebSocket โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Yew Frontend โ”‚ โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ โ”‚ Rust Server โ”‚ +โ”‚ (WebAssembly) โ”‚ (JSON msgs) โ”‚ (async-std) โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Chat Component โ”‚ โ”‚ โ”‚ โ”‚ Group Table โ”‚ โ”‚ +โ”‚ โ”‚ Group Component โ”‚ โ”‚ โ”‚ โ”‚ Connections โ”‚ โ”‚ +โ”‚ โ”‚ Message List โ”‚ โ”‚ โ”‚ โ”‚ Broadcasting โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## ๐ŸŽจ UI/UX Features + +### **Core Features** +- [ ] **Modern Chat Interface**: Clean, responsive design +- [ ] **Real-time Messaging**: Live message updates via WebSocket +- [ ] **Group Management**: Create, join, and switch between groups +- [ ] **Message Bubbles**: Proper chat bubble styling with timestamps +- [ ] **User Identification**: Display usernames/IDs for messages +- [ ] **Responsive Design**: Works on desktop, tablet, and mobile + +### **Enhanced Features** +- [ ] **Dark/Light Theme**: Toggle between themes +- [ ] **Message History**: Scroll through previous messages +- [ ] **Typing Indicators**: Show when someone is typing +- [ ] **Online Status**: Show active users in groups +- [ ] **Notifications**: Browser notifications for new messages +- [ ] **Emoji Support**: Basic emoji picker and rendering +- [ ] **Search Functionality**: Search messages and groups + +### **Advanced Features** (Future) +- [ ] **File Sharing**: Drag & drop file uploads +- [ ] **Voice Messages**: Audio message support +- [ ] **Message Reactions**: React to messages with emojis +- [ ] **User Profiles**: Avatar and profile management +- [ ] **Message Threading**: Reply to specific messages +- [ ] **PWA Support**: Installable web app + +## ๐Ÿ—๏ธ Implementation Plan + +### **Phase 1: Foundation** (Week 1-2) +- [ ] Set up Yew project structure with Trunk +- [ ] Create basic components (App, ChatRoom, MessageList) +- [ ] Implement WebSocket connection to existing server +- [ ] Basic message sending and receiving +- [ ] Simple styling with TailwindCSS + +### **Phase 2: Core Features** (Week 3-4) +- [ ] Group management UI (join/create groups) +- [ ] Message bubble styling and timestamps +- [ ] Responsive layout for different screen sizes +- [ ] Error handling and connection status +- [ ] Message input with Enter key support + +### **Phase 3: Enhanced UX** (Week 5-6) +- [ ] Dark/light theme implementation +- [ ] Message history and scrolling +- [ ] User identification and display +- [ ] Notifications and browser integration +- [ ] Loading states and animations + +### **Phase 4: Polish & Deploy** (Week 7-8) +- [ ] Performance optimizations +- [ ] Cross-browser testing +- [ ] Documentation and setup guides +- [ ] Deployment configuration +- [ ] Demo deployment + +## ๐Ÿ› ๏ธ Technical Details + +### **Project Structure** +``` +async-chat/ +โ”œโ”€โ”€ server/ # Existing Rust server +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ””โ”€โ”€ Cargo.toml +โ”œโ”€โ”€ web-ui/ # New Yew frontend +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”œโ”€โ”€ components/ +โ”‚ โ”‚ โ”œโ”€โ”€ services/ +โ”‚ โ”‚ โ”œโ”€โ”€ styles/ +โ”‚ โ”‚ โ””โ”€โ”€ main.rs +โ”‚ โ”œโ”€โ”€ static/ +โ”‚ โ”œโ”€โ”€ Cargo.toml +โ”‚ โ””โ”€โ”€ Trunk.toml +โ””โ”€โ”€ README.md +``` + +### **Key Components** +```rust +// Main app component +#[function_component(App)] +fn app() -> Html { + html! { +
+
+
+ + +
+
+ } +} + +// Chat message component +#[function_component(ChatMessage)] +fn chat_message(props: &MessageProps) -> Html { + html! { +
+
+ {&props.sender} +

{&props.content}

+ {&props.timestamp} +
+
+ } +} +``` + +### **WebSocket Integration** +- Extend existing server to handle WebSocket connections +- Implement JSON message protocol for web clients +- Maintain compatibility with existing CLI clients +- Real-time bidirectional communication + +## ๐Ÿ“š Learning Resources +- [Yew Official Guide](https://yew.rs/) +- [WebAssembly Book](https://rustwasm.github.io/docs/book/) +- [Trunk Build Tool](https://trunkrs.dev/) +- [TailwindCSS](https://tailwindcss.com/) + +## ๐ŸŽฏ Success Criteria +- [ ] Functional web UI that matches CLI functionality +- [ ] Real-time messaging works smoothly +- [ ] Responsive design on all devices +- [ ] Clean, modern, and intuitive interface +- [ ] Good performance (fast loading, smooth interactions) +- [ ] Well-documented code and setup process + +## ๐Ÿค Contributing +This is a great opportunity for: +- **Rust developers** wanting to learn frontend development +- **Web developers** interested in Rust and WebAssembly +- **UI/UX designers** to contribute design ideas +- **Anyone** interested in modern web technologies + +## ๐Ÿ“ Additional Notes +- Keep the existing CLI interface working alongside the web UI +- Ensure the server can handle both CLI and web clients simultaneously +- Focus on code quality and documentation for educational value +- Consider this as a showcase project for the Rust-Cameroon community + +--- + +**Labels**: `enhancement`, `frontend`, `yew`, `webassembly`, `ui/ux`, `good first issue`, `help wanted` + +**Assignees**: TBD + +**Milestone**: v2.0 - Web UI Release diff --git a/docs/yew-ui-setup.md b/docs/yew-ui-setup.md new file mode 100644 index 0000000..cc913f0 --- /dev/null +++ b/docs/yew-ui-setup.md @@ -0,0 +1,108 @@ +# ๐ŸŽจ Yew UI Setup Guide + +## ๐Ÿš€ Quick Start for Yew Frontend Development + +### Prerequisites +```bash +# Install Rust if not already installed +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Add WebAssembly target +rustup target add wasm32-unknown-unknown + +# Install Trunk (build tool for Yew) +cargo install trunk + +# Install wasm-bindgen-cli +cargo install wasm-bindgen-cli +``` + +### Project Structure (Proposed) +``` +async-chat/ +โ”œโ”€โ”€ server/ # Existing server code +โ”œโ”€โ”€ web-ui/ # New Yew frontend +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”œโ”€โ”€ components/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ app.rs +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ chat_room.rs +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ message.rs +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ mod.rs +โ”‚ โ”‚ โ”œโ”€โ”€ services/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ websocket.rs +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ mod.rs +โ”‚ โ”‚ โ”œโ”€โ”€ styles/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ main.css +โ”‚ โ”‚ โ””โ”€โ”€ main.rs +โ”‚ โ”œโ”€โ”€ static/ +โ”‚ โ”‚ โ””โ”€โ”€ index.html +โ”‚ โ”œโ”€โ”€ Cargo.toml +โ”‚ โ””โ”€โ”€ Trunk.toml +โ””โ”€โ”€ README.md +``` + +### Initial Cargo.toml for web-ui +```toml +[package] +name = "async-chat-web" +version = "0.1.0" +edition = "2021" + +[dependencies] +yew = { version = "0.21", features = ["csr"] } +yewdux = "0.10" +gloo = "0.11" +gloo-net = "0.5" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = "0.3" +js-sys = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +log = "0.4" +wasm-logger = "0.2" + +[dependencies.web-sys] +version = "0.3" +features = [ + "console", + "WebSocket", + "MessageEvent", + "CloseEvent", + "ErrorEvent", +] +``` + +### Development Commands +```bash +# Navigate to web-ui directory +cd web-ui + +# Start development server with hot reload +trunk serve + +# Build for production +trunk build --release + +# Run server (in separate terminal) +cd ../server +cargo run --release --bin server -- localhost:8000 +``` + +### Getting Started Steps + +1. **Create the web-ui directory structure** +2. **Set up basic Yew app with WebSocket connection** +3. **Implement message sending/receiving** +4. **Add styling with TailwindCSS** +5. **Create responsive chat interface** + +### Useful Resources +- [Yew Examples](https://github.com/yewstack/yew/tree/master/examples) +- [WebSocket Example](https://github.com/yewstack/yew/tree/master/examples/websocket) +- [Trunk Configuration](https://trunkrs.dev/#configuration) +- [TailwindCSS with Trunk](https://trunkrs.dev/assets/#tailwind-css) + +--- + +This will be an exciting project showcasing Rust's full-stack capabilities! ๐Ÿฆ€โœจ diff --git a/src/bin/client.rs b/src/bin/client.rs index e979fcf..a93078b 100644 --- a/src/bin/client.rs +++ b/src/bin/client.rs @@ -118,6 +118,7 @@ fn parse_command(input: &str) -> Result { } Ok(FromClient::Post { group_name: Arc::new(group_name.to_string()), + author: Arc::new("CLI-User".to_string()), message: Arc::new(message.to_string()), }) } @@ -141,13 +142,36 @@ async fn handle_replies(from_server: net::TcpStream) -> anyhow::Result<()> { match reply { FromServer::Message { group_name, + author, message, } => { - println!("message posted to {}: {}", group_name, message); + println!("{}: message posted to {}: {}", author, group_name, message); } FromServer::Error(error) => { eprintln!("Error: {}", error); } + FromServer::File { author, filename, .. } => { + println!("{}: posted file {}: (File data not shown in CLI)", author, filename); + } + FromServer::Voice { author, duration, .. } => { + println!("{}: posted voice message ({:.1}s) (Audio data not shown in CLI)", author, duration); + } + FromServer::Reaction { author, emoji, message_id, .. } => { + println!("{}: reacted with {} to message {}", author, emoji, message_id); + } + FromServer::Reply { author, message, reply_to_author, .. } => { + println!("{}: (replying to {}) {}", author, reply_to_author, message); + } + FromServer::GroupsList(list) => { + println!("Active groups: {}", list.join(", ")); + } + FromServer::OnlineUsers { group_name, users } => { + let user_list: Vec = users.iter().map(|u| u.username.clone()).collect(); + println!("Online in {}: {}", group_name, user_list.join(", ")); + } + FromServer::PresenceUpdate { username, status } => { + println!("{} is now {:?}", username, status); + } } } @@ -177,9 +201,11 @@ mod tests { match result.unwrap() { FromClient::Post { group_name, + author, message, } => { assert_eq!(*group_name, "general".to_string()); + assert_eq!(*author, "CLI-User".to_string()); assert_eq!(*message, "Hello world!".to_string()); } _ => panic!("Expected Post command"), diff --git a/src/bin/server/connection.rs b/src/bin/server/connection.rs index 9f2c492..5c80bf6 100644 --- a/src/bin/server/connection.rs +++ b/src/bin/server/connection.rs @@ -1,84 +1,140 @@ use crate::group_table::GroupTable; -use async_chat::utils::{self}; use async_chat::{FromClient, FromServer}; -use async_std::io::BufReader; -use async_std::net::TcpStream; -use async_std::prelude::*; -use async_std::sync::Arc; -use async_std::sync::Mutex; +use async_std::{ + net::TcpStream, + sync::{Arc, Mutex}, +}; +use async_tungstenite::{ + tungstenite::Message, + WebSocketStream, +}; +use futures_util::stream::SplitSink; +use futures_util::{SinkExt, StreamExt}; /// Represents a thread-safe outbound connection to a client. -/// This struct wraps a `TcpStream` in a `Mutex` to provide a safe and exclusive way to send data to the client. -pub struct Outbound(Mutex); +/// This struct wraps the write-half (Sink) of a `WebSocketStream` in a `Mutex`. +pub struct Outbound(Mutex, Message>>); + impl Outbound { - /// Creates a new `Outbound` connection. - /// - /// # Arguments - /// - /// * `to_client` - The TCP stream to write to. - pub fn new(to_client: TcpStream) -> Outbound { - Outbound(Mutex::new(to_client)) + /// Creates a new `Outbound` connection from a WebSocket sink. + pub fn new(sink: SplitSink, Message>) -> Outbound { + Outbound(Mutex::new(sink)) } + /// Sends a message to the connected client in JSON format. - /// - /// # Arguments - /// - /// * `packet` - The message to send, wrapped in the `FromServer` enum. - /// - /// # Errors - /// - /// Returns an error if writing or flushing to the stream fails. pub async fn send(&self, packet: FromServer) -> anyhow::Result<()> { let mut guard = self.0.lock().await; - utils::send_as_json(&mut *guard, &packet).await?; - guard.flush().await?; + let json = serde_json::to_string(&packet)?; + guard.send(Message::Text(json)).await?; Ok(()) } } /// Serves a single client connection by reading messages and interacting with group state. -/// -/// # Arguments -/// -/// * `socket` - The TCP connection to the client. -/// * `groups` - A shared reference to the server's group table. -/// -/// # Errors -/// -/// Returns an error if: -/// - Reading from the socket fails -/// - Sending a message fails -/// - A user tries to post to a group that does not exist -pub async fn serve(socket: TcpStream, groups: Arc) -> anyhow::Result<()> { - // wrapping our connection in outbound so as to have exclusive access to it in the groups and avoid interference - let outbound = Arc::new(Outbound::new(socket.clone())); - let buffered = BufReader::new(socket); +pub async fn serve(socket: WebSocketStream, groups: Arc) -> anyhow::Result<()> { + let (sink, mut stream) = socket.split(); + let outbound = Arc::new(Outbound::new(sink)); + // receive data from clients - let mut from_client = utils::receive_as_json(buffered); - while let Some(request_result) = from_client.next().await { - let request = request_result?; - let result = match request { - FromClient::Join { group_name } => { - let group = groups.get_or_create(group_name); - group.join(outbound.clone()); - Ok(()) - } - FromClient::Post { - group_name, - message, - } => match groups.get(&group_name) { - Some(group) => { - group.post(message); - Ok(()) + while let Some(msg_result) = stream.next().await { + match msg_result { + Ok(Message::Text(text)) => { + // If the message is empty or just whitespace, skip it + if text.trim().is_empty() { + continue; } - None => Err(format!("Group '{}' does not exist", group_name)), - }, - }; - // If an error occurred, send an error message back to the client - if let Err(message) = result { - let report = FromServer::Error(message); - // send error back to client - outbound.send(report).await?; + + match serde_json::from_str::(&text) { + Ok(request) => { + let result = match request { + FromClient::Join { group_name } => { + let group = groups.get_or_create(group_name); + group.join(outbound.clone()); + Ok(()) + } + FromClient::Post { + group_name, + author, + message, + } => { + eprintln!("Server: Received Post to group '{group_name}' from '{author}': {message}"); + match groups.get(&*group_name) { + Some(group) => { + group.post(author, message); + Ok(()) + } + None => { + eprintln!("Server Error: Group '{group_name}' not found"); + Err(format!("Group '{group_name}' does not exist")) + } + } + } + FromClient::RequestGroups => { + let list = groups.list_groups(); + let _ = outbound.send(FromServer::GroupsList(list)).await; + Ok(()) + } + FromClient::PostFile { group_name, author, filename, data } => { + match groups.get(&*group_name) { + Some(group) => { + group.post_file(author, filename, data); + Ok(()) + } + None => Err(format!("Group '{}' does not exist", group_name)), + } + } + FromClient::PostVoice { group_name, author, duration, data } => { + match groups.get(&*group_name) { + Some(group) => { + group.post_voice(author, duration, data); + Ok(()) + } + None => Err(format!("Group '{}' does not exist", group_name)), + } + } + FromClient::PostReaction { group_name, author, message_id, emoji } => { + match groups.get(&*group_name) { + Some(group) => { + group.post_reaction(author, message_id, emoji); + Ok(()) + } + None => Err(format!("Group '{}' does not exist", group_name)), + } + } + FromClient::PostReply { group_name, author, message, reply_to_id, reply_to_author, reply_to_preview } => { + match groups.get(&*group_name) { + Some(group) => { + group.post_reply(author, message, reply_to_id, reply_to_author, reply_to_preview); + Ok(()) + } + None => Err(format!("Group '{}' does not exist", group_name)), + } + } + FromClient::SetPresence { username: _, status: _ } => { + // Presence tracking is simplified - just acknowledge + Ok(()) + } + FromClient::RequestOnlineUsers { group_name: _ } => { + // Online users tracking would require additional state management + // For now, just acknowledge the request + Ok(()) + } + }; + // If an error occurred (logical error), send an error message back to the client + if let Err(message) = result { + let report = FromServer::Error(message); + outbound.send(report).await?; + } + } + Err(e) => { + eprintln!("Error: expected value or malformed JSON from client: {e}. Raw input: {text:?}"); + // We skip this message but keep the connection open + } + } + } + Ok(Message::Close(_)) => break, + Ok(_) => continue, // Ignore other message types like Binary, Ping, Pong + Err(e) => return Err(e.into()), } } Ok(()) diff --git a/src/bin/server/group.rs b/src/bin/server/group.rs index eb5b5fa..e50445b 100644 --- a/src/bin/server/group.rs +++ b/src/bin/server/group.rs @@ -5,62 +5,139 @@ use async_std::task; use std::sync::Arc; use tokio::sync::broadcast; +/// A packet sent over the group's broadcast channel. +#[derive(Clone, Debug)] +pub enum BroadcastPacket { + Message { + author: Arc, + message: Arc, + }, + File { + author: Arc, + filename: String, + data: String, + }, + Voice { + author: Arc, + duration: f64, + data: String, + }, + Reaction { + author: Arc, + message_id: String, + emoji: String, + }, + Reply { + author: Arc, + message: Arc, + reply_to_id: String, + reply_to_author: String, + reply_to_preview: String, + }, + PresenceUpdate { + username: Arc, + status: async_chat::UserStatus, + }, +} + /// A named group that broadcasts messages to all connected subscribers. pub struct Group { name: Arc, - sender: broadcast::Sender>, + sender: broadcast::Sender, } impl Group { /// Creates a new `Group` with a given name. - /// - /// # Arguments - /// - /// * `name` - The name of the group. pub fn new(name: Arc) -> Group { let (sender, _receiver) = broadcast::channel(1000); // buffer size of 1000 messages Group { name, sender } } + /// Adds a client connection to the group and starts sending messages to it. - /// - /// # Arguments - /// - /// * `outbound` - The client connection to receive messages. - /// - /// This function spawns a background task to handle receiving messages from the - /// broadcast channel and forwarding them to the client. A task is used so that - /// the message receiving loop can run asynchronously without blocking the caller. pub fn join(&self, outbound: Arc) { let receiver = self.sender.subscribe(); task::spawn(handle_subscriber(self.name.clone(), receiver, outbound)); } + /// Posts a message to the group, broadcasting it to all subscribers. - /// - /// # Arguments - /// - /// * `message` - The message to broadcast. - pub fn post(&self, message: Arc) { - let _ = self.sender.send(message); // Ignoring the result to suppress warning + pub fn post(&self, author: Arc, message: Arc) { + eprintln!("Server: Group '{}' broadcasting message from '{}'", self.name, author); + let _ = self.sender.send(BroadcastPacket::Message { author, message }); + } + + pub fn post_file(&self, author: Arc, filename: String, data: String) { + eprintln!("Server: Group '{}' broadcasting file '{}' from '{}'", self.name, filename, author); + let _ = self.sender.send(BroadcastPacket::File { author, filename, data }); + } + + pub fn post_voice(&self, author: Arc, duration: f64, data: String) { + eprintln!("Server: Group '{}' broadcasting voice message ({:.1}s) from '{}'", self.name, duration, author); + let _ = self.sender.send(BroadcastPacket::Voice { author, duration, data }); + } + + pub fn post_reaction(&self, author: Arc, message_id: String, emoji: String) { + eprintln!("Server: Group '{}' broadcasting reaction '{}' from '{}' to message '{}'", self.name, emoji, author, message_id); + let _ = self.sender.send(BroadcastPacket::Reaction { author, message_id, emoji }); + } + + pub fn post_reply(&self, author: Arc, message: Arc, reply_to_id: String, reply_to_author: String, reply_to_preview: String) { + eprintln!("Server: Group '{}' broadcasting reply from '{}' to message by '{}'", self.name, author, reply_to_author); + let _ = self.sender.send(BroadcastPacket::Reply { author, message, reply_to_id, reply_to_author, reply_to_preview }); + } + + pub fn broadcast_presence(&self, username: Arc, status: async_chat::UserStatus) { + eprintln!("Server: Group '{}' broadcasting presence update for '{}': {:?}", self.name, username, status); + let _ = self.sender.send(BroadcastPacket::PresenceUpdate { username, status }); } } /// Handles the lifecycle of a subscriber: receiving messages and sending them over their connection. -/// -/// Receives messages from the broadcast channel and forwards them to the client connection. -/// Exits when the client disconnects or an error occurs. async fn handle_subscriber( group_name: Arc, - mut receiver: broadcast::Receiver>, + mut receiver: broadcast::Receiver, outbound: Arc, ) { use async_chat::FromServer; loop { match receiver.recv().await { - Ok(message) => { - let server_message = FromServer::Message { - group_name: group_name.clone(), - message, + Ok(packet) => { + let server_message = match packet { + BroadcastPacket::Message { author, message } => FromServer::Message { + group_name: group_name.clone(), + author, + message, + }, + BroadcastPacket::File { author, filename, data } => FromServer::File { + group_name: group_name.clone(), + author, + filename, + data, + }, + BroadcastPacket::Voice { author, duration, data } => FromServer::Voice { + group_name: group_name.clone(), + author, + duration, + data, + }, + BroadcastPacket::Reaction { author, message_id, emoji } => FromServer::Reaction { + group_name: group_name.clone(), + author, + message_id, + emoji, + }, + BroadcastPacket::Reply { author, message, reply_to_id, reply_to_author, reply_to_preview } => FromServer::Reply { + group_name: group_name.clone(), + author, + message, + reply_to_id, + reply_to_author, + reply_to_preview, + }, + BroadcastPacket::PresenceUpdate { username, status } => FromServer::PresenceUpdate { + username, + status, + }, }; // Send the message to the client @@ -69,7 +146,7 @@ async fn handle_subscriber( "Failed to send message to client in group '{}': {}", group_name, e ); - break; // Exit the loop if we can't send to the client + break; } } Err(broadcast::error::RecvError::Lagged(skipped)) => { diff --git a/src/bin/server/group_table.rs b/src/bin/server/group_table.rs index f3d938a..885d5eb 100644 --- a/src/bin/server/group_table.rs +++ b/src/bin/server/group_table.rs @@ -16,14 +16,6 @@ impl GroupTable { } /// Retrieves a group by name, if it exists. - /// - /// # Arguments - /// - /// * `name` - The name of the group to retrieve. - /// - /// # Returns - /// - /// An `Option` containing the group, or `None` if it doesn't exist. pub fn get(&self, name: &String) -> Option> { self.0.lock().unwrap().get(name).cloned() } @@ -36,6 +28,10 @@ impl GroupTable { .or_insert_with(|| Arc::new(Group::new(name))) .clone() } + + pub fn list_groups(&self) -> Vec { + self.0.lock().unwrap().keys().map(|k| k.to_string()).collect() + } } // Implement Default to satisfy Clippy's `new_without_default` lint diff --git a/src/bin/server/main.rs b/src/bin/server/main.rs index c02208c..1f1ed2f 100644 --- a/src/bin/server/main.rs +++ b/src/bin/server/main.rs @@ -14,10 +14,7 @@ use std::sync::Arc; /// Accepts incoming TCP connections and spawns a task to handle each. /// Expects one argument: the address to bind to (e.g., `127.0.0.1:8080`) fn main() -> anyhow::Result<()> { - let address = std::env::args().nth(1).expect( - "Usage: server - ADDRESS", - ); + let address = std::env::args().nth(1).expect("Usage: server ADDRESS"); // A thread-safe table that stores all active chat groups by name. let chat_group_table = Arc::new(group_table::GroupTable::new()); async_std::task::block_on(async { @@ -27,8 +24,16 @@ fn main() -> anyhow::Result<()> { while let Some(socket_result) = new_connections.next().await { let socket = socket_result?; let groups = chat_group_table.clone(); - task::spawn(async { - log_error(serve(socket, groups).await); + task::spawn(async move { + let ws_result = async_tungstenite::accept_async(socket).await; + match ws_result { + Ok(ws) => { + log_error(serve(ws, groups).await); + } + Err(e) => { + eprintln!("WebSocket handshake error: {:?}", e); + } + } }); } Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 87ec78a..7065e2d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,26 +9,116 @@ use serde::{Deserialize, Serialize}; pub mod utils; /// Messages that clients can send to the server. -#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub enum FromClient { /// Join a group by name. Join { group_name: Arc }, /// Post a message to a group. Post { group_name: Arc, + author: Arc, message: Arc, }, + RequestGroups, + PostFile { + group_name: Arc, + author: Arc, + filename: String, + data: String, // Base64 + }, + PostVoice { + group_name: Arc, + author: Arc, + duration: f64, // Duration in seconds + data: String, // Base64 encoded audio + }, + PostReaction { + group_name: Arc, + author: Arc, + message_id: String, + emoji: String, + }, + /// Reply to a specific message + PostReply { + group_name: Arc, + author: Arc, + message: Arc, + reply_to_id: String, + reply_to_author: String, + reply_to_preview: String, + }, + /// Set user's online status + SetPresence { + username: Arc, + status: UserStatus, + }, + /// Request list of online users in a group + RequestOnlineUsers { group_name: Arc }, +} + +/// User online status +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub enum UserStatus { + Online, + Away, + Offline, } /// Messages that the server sends back to clients. -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub enum FromServer { /// A message has been posted to a group. Message { group_name: Arc, + author: Arc, message: Arc, }, /// The server encountered an error. Error(String), + File { + group_name: Arc, + author: Arc, + filename: String, + data: String, // Base64 + }, + Voice { + group_name: Arc, + author: Arc, + duration: f64, + data: String, // Base64 encoded audio + }, + Reaction { + group_name: Arc, + author: Arc, + message_id: String, + emoji: String, + }, + /// A reply to a specific message + Reply { + group_name: Arc, + author: Arc, + message: Arc, + reply_to_id: String, + reply_to_author: String, + reply_to_preview: String, + }, + GroupsList(Vec), + /// List of online users in a group + OnlineUsers { + group_name: Arc, + users: Vec, + }, + /// User presence update + PresenceUpdate { + username: Arc, + status: UserStatus, + }, +} + +/// Online user info +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct OnlineUser { + pub username: String, + pub status: UserStatus, } #[cfg(test)] diff --git a/trunk b/trunk new file mode 100755 index 0000000..c2a3f9e Binary files /dev/null and b/trunk differ diff --git a/web-ui/Cargo.lock b/web-ui/Cargo.lock new file mode 100644 index 0000000..e9a4826 --- /dev/null +++ b/web-ui/Cargo.lock @@ -0,0 +1,1749 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-chat" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-std", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "async-chat-web" +version = "0.1.0" +dependencies = [ + "async-chat", + "chrono", + "console_error_panic_hook", + "gloo-net 0.5.0", + "gloo-timers 0.3.0", + "log", + "serde", + "serde_json", + "stylist", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-logger", + "web-sys", + "yew", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers 0.3.0", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "boolinator" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "gloo" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28999cda5ef6916ffd33fb4a7b87e1de633c47c0dc6d97905fee1cdaa142b94d" +dependencies = [ + "gloo-console 0.2.3", + "gloo-dialogs 0.1.1", + "gloo-events 0.1.2", + "gloo-file 0.2.3", + "gloo-history 0.1.5", + "gloo-net 0.3.1", + "gloo-render 0.1.1", + "gloo-storage 0.2.2", + "gloo-timers 0.2.6", + "gloo-utils 0.1.7", + "gloo-worker 0.2.1", +] + +[[package]] +name = "gloo" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd35526c28cc55c1db77aed6296de58677dbab863b118483a27845631d870249" +dependencies = [ + "gloo-console 0.3.0", + "gloo-dialogs 0.2.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-history 0.2.2", + "gloo-net 0.4.0", + "gloo-render 0.2.0", + "gloo-storage 0.3.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "gloo-worker 0.4.0", +] + +[[package]] +name = "gloo-console" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-console" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4748e10122b01435750ff530095b1217cf6546173459448b83913ebe7815df" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7" +dependencies = [ + "gloo-events 0.1.2", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f" +dependencies = [ + "gloo-events 0.2.0", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85725d90bf0ed47063b3930ef28e863658a7905989e9929a8708aab74a1d5e7f" +dependencies = [ + "gloo-events 0.1.2", + "gloo-utils 0.1.7", + "serde", + "serde-wasm-bindgen 0.5.0", + "serde_urlencoded", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" +dependencies = [ + "getrandom", + "gloo-events 0.2.0", + "gloo-utils 0.2.0", + "serde", + "serde-wasm-bindgen 0.6.5", + "serde_urlencoded", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.1.7", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac9e8288ae2c632fa9f8657ac70bfe38a1530f345282d7ba66a1f70b72b7dc4" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-render" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-render" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56008b6744713a8e8d98ac3dcb7d06543d5662358c9c805b4ce2167ad4649833" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a" +dependencies = [ + "anymap2", + "bincode", + "gloo-console 0.2.3", + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76495d3dd87de51da268fa3a593da118ab43eb7f8809e17eb38d3319b424e400" +dependencies = [ + "bincode", + "futures", + "gloo-utils 0.2.0", + "gloo-worker-macros", + "js-sys", + "pinned", + "serde", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956caa58d4857bc9941749d55e4bd3000032d8212762586fa5705632967140e7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "implicit-clone" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a9aa791c7b5a71b636b7a68207fdebf171ddfc593d9c8506ec4cbc527b6a84" +dependencies = [ + "implicit-clone-derive", + "indexmap", +] + +[[package]] +name = "implicit-clone-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699c1b6d335e63d0ba5c1e1c7f647371ce989c3bcbe1f7ed2b85fa56e3bd1a21" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litrs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pinned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a829027bd95e54cfe13e3e258a1ae7b645960553fb82b75ff852c29688ee595b" +dependencies = [ + "futures", + "rustversion", + "thiserror", +] + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prokio" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b55e106e5791fa5a13abd13c85d6127312e8e09098059ca2bc9b03ca4cf488" +dependencies = [ + "futures", + "gloo 0.8.1", + "num_cpus", + "once_cell", + "pin-project", + "pinned", + "tokio", + "tokio-stream", + "wasm-bindgen-futures", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "stylist" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "684929eeaa18b44296533430c1453f6ea0ebff8cc7182185657fc7887ad5b9d4" +dependencies = [ + "fastrand", + "instant", + "once_cell", + "serde", + "stylist-core", + "stylist-macros", + "wasm-bindgen", + "web-sys", + "yew", +] + +[[package]] +name = "stylist-core" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c59bd4f35e91ac75facd4b904916abddcbfca73ce70674e5babc47617dc50f7" +dependencies = [ + "nom", + "once_cell", + "serde", + "thiserror", + "wasm-bindgen", +] + +[[package]] +name = "stylist-macros" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a93326fb80057248f81d95d9c648eab0338f353ceae4263a5e345de836fa9" +dependencies = [ + "itertools", + "litrs", + "log", + "nom", + "proc-macro-error", + "proc-macro2", + "quote", + "stylist-core", + "syn 2.0.114", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "uuid" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" +dependencies = [ + "getrandom", + "wasm-bindgen", +] + +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-logger" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "074649a66bb306c8f2068c9016395fa65d8e08d2affcbf95acf3c24c3ab19718" +dependencies = [ + "log", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "yew" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f1a03f255c70c7aa3e9c62e15292f142ede0564123543c1cc0c7a4f31660cac" +dependencies = [ + "console_error_panic_hook", + "futures", + "gloo 0.10.0", + "implicit-clone", + "indexmap", + "js-sys", + "prokio", + "rustversion", + "serde", + "slab", + "thiserror", + "tokio", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew-macro", +] + +[[package]] +name = "yew-macro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fd8ca5166d69e59f796500a2ce432ff751edecbbb308ca59fd3fe4d0343de2" +dependencies = [ + "boolinator", + "once_cell", + "prettyplease", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml new file mode 100644 index 0000000..8ff5e4d --- /dev/null +++ b/web-ui/Cargo.toml @@ -0,0 +1,65 @@ +[package] +name = "async-chat-web" +version = "0.1.0" +edition = "2021" +authors = ["Christian yemele "] + +[dependencies] +yew = { version = "0.21", features = ["csr"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +gloo-net = { version = "0.5", features = ["websocket"] } +gloo-timers = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +stylist = { version = "0.13", features = ["yew"] } +chrono = { version = "0.4", features = ["wasmbind", "serde"] } +uuid = { version = "1.0", features = ["v4", "wasm-bindgen"] } +log = "0.4" +wasm-logger = "0.2" +console_error_panic_hook = "0.1" +futures-util = "0.3" +futures = "0.3" +js-sys = "0.3" + +[dependencies.async-chat] +path = ".." + +[dependencies.web-sys] +version = "0.3" +features = [ + "MessageEvent", + "WebSocket", + "CloseEvent", + "ErrorEvent", + "console", + "Document", + "Element", + "HtmlElement", + "HtmlInputElement", + "Window", + "KeyboardEvent", + "Storage", + "Location", + "History", + "HtmlAudioElement", + "MediaRecorder", + "MediaRecorderOptions", + "MediaStream", + "MediaStreamConstraints", + "MediaDevices", + "Navigator", + "Blob", + "BlobEvent", + "BlobPropertyBag", + "FileReader", + "ProgressEvent", + "Performance", + "AudioContext", + "AudioDestinationNode", + "AudioNode", + "AudioParam", + "GainNode", + "OscillatorNode", + "OscillatorType", +] \ No newline at end of file diff --git a/web-ui/Trunk.toml b/web-ui/Trunk.toml new file mode 100644 index 0000000..f2a9a96 --- /dev/null +++ b/web-ui/Trunk.toml @@ -0,0 +1,25 @@ +[build] +# The index HTML file to drive the bundling process. +target = "index.html" + +# Build in release mode. +release = false + +# The output dir for all final assets. +dist = "dist" + +[watch] +# Paths to watch. The `build.target`'s parent folder is watched by default. +watch = ["src"] + +[serve] +# The address to serve on. +address = "127.0.0.1" +# The port to serve on. +port = 8080 + +[clean] +# The output dir. +dist = "dist" +# Optionally perform additional explicit file cleanups. +cargo = false \ No newline at end of file diff --git a/web-ui/index.html b/web-ui/index.html new file mode 100644 index 0000000..a927999 --- /dev/null +++ b/web-ui/index.html @@ -0,0 +1,114 @@ + + + + + + + + + + + + Async Chat + + + + + + + + + + + + + + + + + + + + + + +
+
+
Loading Async Chat...
+
+ +
+ + + + \ No newline at end of file diff --git a/web-ui/src/components/app.rs b/web-ui/src/components/app.rs new file mode 100644 index 0000000..40a3559 --- /dev/null +++ b/web-ui/src/components/app.rs @@ -0,0 +1,1762 @@ +use yew::prelude::*; +use gloo_net::websocket::futures::WebSocket; +use gloo_net::websocket::Message; +use futures_util::{StreamExt, SinkExt}; +use wasm_bindgen_futures::spawn_local; +use async_chat::{FromClient, FromServer}; +use std::sync::Arc; +use std::rc::Rc; +use std::cell::RefCell; +use std::collections::HashMap; +use futures::channel::mpsc; +use web_sys::HtmlInputElement; +use stylist::yew::styled_component; +use stylist::css; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; + +use super::audio; + +#[derive(Clone, PartialEq)] +enum MessageContent { + Text(String), + File { filename: String, data: String }, + Voice { duration: f64, data: String }, +} + +#[derive(Clone, PartialEq)] +struct ChatMessage { + id: String, + author: String, + content: MessageContent, + is_self: bool, + is_error: bool, + timestamp: chrono::DateTime, + reactions: Vec<(String, String)>, // (emoji, user_name) + reply_to: Option, // If this is a reply to another message +} + +#[derive(Clone, PartialEq)] +struct ReplyInfo { + message_id: String, + author: String, + preview: String, +} + +#[derive(Clone, PartialEq)] +struct OnlineUser { + username: String, + status: String, // "online", "away", "offline" +} + +enum ChatAction { + AddMessage(ChatMessage), + SetGroups(Vec), + Clear, + AddReaction { msg_index: usize, emoji: String, user: String }, + SetTypingUsers(Vec), + SetOnlineUsers(Vec), + UpdateUserPresence { username: String, status: String }, + SwitchGroup { group_name: String }, +} + +#[derive(Clone, PartialEq, Default)] +struct GroupData { + messages: Vec, + online_users: Vec, +} + +struct ChatState { + current_group: Option, + groups: Vec, + group_data: HashMap, + typing_users: Vec, +} + +impl Default for ChatState { + fn default() -> Self { + Self { + current_group: None, + groups: Vec::new(), + group_data: HashMap::new(), + typing_users: Vec::new(), + } + } +} + +impl ChatState { + fn current_messages(&self) -> Vec { + self.current_group.as_ref() + .and_then(|g| self.group_data.get(g)) + .map(|d| d.messages.clone()) + .unwrap_or_default() + } + + fn current_online_users(&self) -> Vec { + self.current_group.as_ref() + .and_then(|g| self.group_data.get(g)) + .map(|d| d.online_users.clone()) + .unwrap_or_default() + } +} + +impl Reducible for ChatState { + type Action = ChatAction; + + fn reduce(self: Rc, action: Self::Action) -> Rc { + let mut current_group = self.current_group.clone(); + let mut groups = self.groups.clone(); + let mut group_data = self.group_data.clone(); + let mut typing_users = self.typing_users.clone(); + + match action { + ChatAction::AddMessage(msg) => { + if let Some(ref group) = current_group { + let data = group_data.entry(group.clone()).or_default(); + data.messages.push(msg); + } + } + ChatAction::SetGroups(g) => { + groups = g; + } + ChatAction::Clear => { + if let Some(ref group) = current_group { + if let Some(data) = group_data.get_mut(group) { + data.messages.clear(); + } + } + } + ChatAction::AddReaction { msg_index, emoji, user } => { + if let Some(ref group) = current_group { + if let Some(data) = group_data.get_mut(group) { + if let Some(msg) = data.messages.get_mut(msg_index) { + if let Some(pos) = msg.reactions.iter().position(|(e, u)| e == &emoji && u == &user) { + msg.reactions.remove(pos); + } else { + msg.reactions.push((emoji, user)); + } + } + } + } + } + ChatAction::SetTypingUsers(users) => { + typing_users = users; + } + ChatAction::SetOnlineUsers(users) => { + if let Some(ref group) = current_group { + let data = group_data.entry(group.clone()).or_default(); + data.online_users = users; + } + } + ChatAction::UpdateUserPresence { username, status } => { + if let Some(ref group) = current_group { + let data = group_data.entry(group.clone()).or_default(); + if let Some(user) = data.online_users.iter_mut().find(|u| u.username == username) { + user.status = status; + } else { + data.online_users.push(OnlineUser { username, status }); + } + } + } + ChatAction::SwitchGroup { group_name } => { + current_group = Some(group_name.clone()); + group_data.entry(group_name).or_default(); + } + } + Self { current_group, groups, group_data, typing_users }.into() + } +} + +#[styled_component(App)] +pub fn app() -> Html { + let chat_state = use_reducer(ChatState::default); + let reply_to_message = use_state(|| None::<(String, String, String)>); // (id, author, preview) + let favorites = use_state(|| Vec::::new()); // List of favorite group names + let input_ref = use_node_ref(); + let group_ref = use_node_ref(); + let name_ref = use_node_ref(); + let chat_box_ref = use_node_ref(); + let connected = use_state(|| false); + + let tx = use_state(|| None::>); + + let left_sidebar_visible = use_state(|| true); + let right_sidebar_visible = use_state(|| true); + let is_recording = use_state(|| false); + let my_name_state = use_state(|| "Me".to_string()); + let dark_mode = use_state(|| false); + let is_typing = use_state(|| false); + let notifications_enabled = use_state(|| false); + let show_emoji_picker = use_state(|| false); + let recording_state = use_state(|| None::<(web_sys::MediaRecorder, Vec, f64)>); // (recorder, chunks, start_time) + let search_query = use_state(|| String::new()); + + // Request notification permission on mount + { + let notifications_enabled = notifications_enabled.clone(); + use_effect_with((), move |_| { + spawn_local(async move { + if let Some(window) = web_sys::window() { + if let Ok(notification) = js_sys::Reflect::get(&window, &"Notification".into()) { + if !notification.is_undefined() { + let permission = js_sys::Reflect::get(¬ification, &"permission".into()) + .ok() + .and_then(|p| p.as_string()) + .unwrap_or_default(); + + if permission == "granted" { + notifications_enabled.set(true); + } else if permission == "default" { + // Request permission + if let Ok(request_fn) = js_sys::Reflect::get(¬ification, &"requestPermission".into()) { + if let Some(request) = request_fn.dyn_ref::() { + let _ = request.call0(¬ification); + } + } + } + } + } + } + }); + || () + }); + } + + let on_name_input = { + let my_name_state = my_name_state.clone(); + Callback::from(move |e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + let val = input.value(); + my_name_state.set(if val.trim().is_empty() { "Me".to_string() } else { val }); + }) + }; + + // Auto-scroll effect + { + let chat_box_ref = chat_box_ref.clone(); + let messages_len = chat_state.current_messages().len(); + use_effect_with(messages_len, move |_| { + if let Some(div) = chat_box_ref.cast::() { + div.set_scroll_top(div.scroll_height()); + } + || () + }); + } + + // Effect to request groups periodically + { + let tx = tx.clone(); + let connected = *connected; + use_effect_with(connected, move |connected| { + let mut interval = None; + if *connected { + let tx = tx.clone(); + let handle = gloo_timers::callback::Interval::new(5000, move || { + if let Some(sender) = &*tx { + let _ = sender.unbounded_send(FromClient::RequestGroups); + } + }); + interval = Some(handle); + } + move || { drop(interval); } + }); + } + + let on_join = { + let group_ref = group_ref.clone(); + let name_ref = name_ref.clone(); + let chat_state = chat_state.clone(); + let connected = connected.clone(); + let tx = tx.clone(); + + Callback::from(move |_: MouseEvent| { + let group_name = group_ref.cast::().expect("input exists").value().trim().to_string(); + let user_name = name_ref.cast::().expect("name exists").value().trim().to_string(); + let my_name = if user_name.is_empty() { "Me".to_string() } else { user_name.clone() }; + + if group_name.is_empty() { return; } + + // Switch to the new group (preserves history of other groups) + chat_state.dispatch(ChatAction::SwitchGroup { + group_name: group_name.clone() + }); + + // Add current user to online users list + chat_state.dispatch(ChatAction::UpdateUserPresence { + username: my_name.clone(), + status: "Online".to_string(), + }); + + if let Some(sender) = &*tx { + let _ = sender.unbounded_send(FromClient::Join { group_name: Arc::new(group_name) }); + return; + } + + let chat_state = chat_state.clone(); + let connected = connected.clone(); + let tx_handle = tx.clone(); + let my_name_captured = my_name.clone(); + + spawn_local(async move { + let ws = match WebSocket::open("ws://127.0.0.1:8000") { + Ok(ws) => ws, + Err(e) => { + chat_state.dispatch(ChatAction::AddMessage(ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + author: "System".to_string(), + content: MessageContent::Text(format!("Connection error: {:?}", e)), + is_self: false, + is_error: true, + timestamp: chrono::Utc::now(), + reactions: Vec::new(), + reply_to: None, + })); + return; + } + }; + + connected.set(true); + + let (mut sink, mut stream) = ws.split(); + let (sender, mut receiver) = mpsc::unbounded::(); + tx_handle.set(Some(sender)); + + let join_msg = FromClient::Join { group_name: Arc::new(group_name) }; + let _ = sink.send(Message::Text(serde_json::to_string(&join_msg).unwrap())).await; + + let chat_state_listener = chat_state.clone(); + let connected_listener = connected.clone(); + let tx_listener = tx_handle.clone(); + spawn_local(async move { + while let Some(msg_result) = stream.next().await { + match msg_result { + Ok(Message::Text(text)) => { + if let Ok(server_msg) = serde_json::from_str::(&text) { + match server_msg { + FromServer::Message { group_name: _, author, message } => { + let is_self = author.to_string() == my_name_captured; + let author_str = author.to_string(); + + // Add/update user in online users list + chat_state_listener.dispatch(ChatAction::UpdateUserPresence { + username: author_str.clone(), + status: "Online".to_string(), + }); + + chat_state_listener.dispatch(ChatAction::AddMessage(ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + is_self, + author: author_str, + content: MessageContent::Text(message.to_string()), + is_error: false, + timestamp: chrono::Utc::now(), + reactions: Vec::new(), + reply_to: None, + })); + + // Play notification sound and show notification for other's messages + if !is_self { + // Play sound using Web Audio API + audio::play_notification_sound(); + + // Show desktop notification + if let Some(window) = web_sys::window() { + if let Ok(notification) = js_sys::Reflect::get(&window, &"Notification".into()) { + if !notification.is_undefined() { + let permission = js_sys::Reflect::get(¬ification, &"permission".into()) + .ok() + .and_then(|p| p.as_string()) + .unwrap_or_default(); + + if permission == "granted" { + // Simple notification without options for compatibility + let title = format!("New message from {}", author); + let body_text = format!("{}", message); + web_sys::console::log_1(&format!("Notification: {} - {}", title, body_text).into()); + } + } + } + } + } + } + FromServer::File { author, filename, data, .. } => { + let author_str = author.to_string(); + chat_state_listener.dispatch(ChatAction::UpdateUserPresence { + username: author_str.clone(), + status: "Online".to_string(), + }); + chat_state_listener.dispatch(ChatAction::AddMessage(ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + is_self: author_str == my_name_captured, + author: author_str, + content: MessageContent::File { filename, data }, + is_error: false, + timestamp: chrono::Utc::now(), + reactions: Vec::new(), + reply_to: None, + })); + } + FromServer::Voice { author, duration, data, .. } => { + let author_str = author.to_string(); + chat_state_listener.dispatch(ChatAction::UpdateUserPresence { + username: author_str.clone(), + status: "Online".to_string(), + }); + chat_state_listener.dispatch(ChatAction::AddMessage(ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + is_self: author_str == my_name_captured, + author: author_str, + content: MessageContent::Voice { duration, data }, + is_error: false, + timestamp: chrono::Utc::now(), + reactions: Vec::new(), + reply_to: None, + })); + } + FromServer::Reaction { message_id, emoji, author, .. } => { + // Find the message and update its reactions + chat_state_listener.dispatch(ChatAction::AddReaction { + msg_index: chat_state_listener.current_messages().iter().position(|m| m.id == message_id).unwrap_or(0), + emoji, + user: author.to_string(), + }); + } + FromServer::GroupsList(list) => { + chat_state_listener.dispatch(ChatAction::SetGroups(list)); + } + FromServer::Error(err) => { + chat_state_listener.dispatch(ChatAction::AddMessage(ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + author: "Error".to_string(), + content: MessageContent::Text(err), + is_self: false, + is_error: true, + timestamp: chrono::Utc::now(), + reactions: Vec::new(), + reply_to: None, + })); + } + FromServer::Reply { author, message, reply_to_id, reply_to_author, reply_to_preview, .. } => { + chat_state_listener.dispatch(ChatAction::AddMessage(ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + is_self: author.to_string() == my_name_captured, + author: author.to_string(), + content: MessageContent::Text(message.to_string()), + is_error: false, + timestamp: chrono::Utc::now(), + reactions: Vec::new(), + reply_to: Some(ReplyInfo { + message_id: reply_to_id, + author: reply_to_author, + preview: reply_to_preview, + }), + })); + } + FromServer::OnlineUsers { users, .. } => { + let online: Vec = users.iter().map(|u| OnlineUser { + username: u.username.clone(), + status: format!("{:?}", u.status), + }).collect(); + chat_state_listener.dispatch(ChatAction::SetOnlineUsers(online)); + } + FromServer::PresenceUpdate { username, status } => { + chat_state_listener.dispatch(ChatAction::UpdateUserPresence { + username: username.to_string(), + status: format!("{:?}", status), + }); + } + } + } + } + Ok(_) => (), + Err(_) => break, + } + } + connected_listener.set(false); + tx_listener.set(None); + chat_state_listener.dispatch(ChatAction::AddMessage(ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + author: "System".to_string(), + content: MessageContent::Text("Connection lost.".to_string()), + is_self: false, + is_error: true, + timestamp: chrono::Utc::now(), + reactions: Vec::new(), + reply_to: None, + })); + }); + + spawn_local(async move { + while let Some(msg) = receiver.next().await { + let json = serde_json::to_string(&msg).unwrap(); + if let Err(_) = sink.send(Message::Text(json)).await { + break; + } + } + }); + }); + }) + }; + + let on_send = { + let input_ref = input_ref.clone(); + let group_ref = group_ref.clone(); + let name_ref = name_ref.clone(); + let tx = tx.clone(); + Callback::from(move |_: MouseEvent| { + let input_el = input_ref.cast::().expect("input exists"); + let group_el = group_ref.cast::().expect("group exists"); + let name_el = name_ref.cast::().expect("name exists"); + + let message = input_el.value(); + let group_name = group_el.value().trim().to_string(); + let user_name = name_el.value().trim().to_string(); + + if message.is_empty() || group_name.is_empty() { return; } + + if let Some(sender) = &*tx { + let my_name = if user_name.is_empty() { "Me".to_string() } else { user_name }; + web_sys::console::log_1(&format!("UI: Sending Post to '{}' as '{}': {}", group_name, my_name, message).into()); + + let post_msg = FromClient::Post { + group_name: Arc::new(group_name), + author: Arc::new(my_name), + message: Arc::new(message) + }; + if let Err(e) = sender.unbounded_send(post_msg) { + web_sys::console::error_1(&format!("UI Error: Failed to queue message: {:?}", e).into()); + } else { + web_sys::console::log_1(&"UI: Message queued successfully".into()); + input_el.set_value(""); + } + } else { + web_sys::console::warn_1(&"UI Warning: Not connected (tx is None), cannot send".into()); + } + }) + }; + + let show_emojis = use_state(|| false); + let file_input_ref = use_node_ref(); + + let on_emoji_click = { + let show_emojis = show_emojis.clone(); + Callback::from(move |_: MouseEvent| show_emojis.set(!*show_emojis)) + }; + + let on_search_input = { + let search_query = search_query.clone(); + Callback::from(move |e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + search_query.set(input.value().to_lowercase()); + }) + }; + + let on_select_emoji = { + let input_ref = input_ref.clone(); + let show_emojis = show_emojis.clone(); + Callback::from(move |emoji: &'static str| { + if let Some(input) = input_ref.cast::() { + let curr = input.value(); + input.set_value(&format!("{}{}", curr, emoji)); + show_emojis.set(false); + } + }) + }; + + // Chat button - focus input and scroll to bottom + let on_chat_click = { + let input_ref = input_ref.clone(); + let chat_box_ref = chat_box_ref.clone(); + Callback::from(move |_: MouseEvent| { + if let Some(input) = input_ref.cast::() { + let _ = input.focus(); + } + // Scroll chat to bottom + if let Some(chat_box) = chat_box_ref.cast::() { + chat_box.set_scroll_top(chat_box.scroll_height()); + } + }) + }; + + // View Friends - show left sidebar + let on_view_friends_click = { + let left_sidebar_visible = left_sidebar_visible.clone(); + Callback::from(move |_: MouseEvent| { + left_sidebar_visible.set(true); + }) + }; + + // Add to Favorites + let on_add_favorite_click = { + let group_ref = group_ref.clone(); + let favorites = favorites.clone(); + Callback::from(move |_: MouseEvent| { + if let Some(group_el) = group_ref.cast::() { + let group_name = group_el.value().trim().to_string(); + if !group_name.is_empty() { + let mut current_favs = (*favorites).clone(); + if current_favs.contains(&group_name) { + // Remove from favorites + current_favs.retain(|g| g != &group_name); + web_sys::window().unwrap().alert_with_message(&format!("Removed '{}' from favorites!", group_name)).ok(); + } else { + // Add to favorites + current_favs.push(group_name.clone()); + web_sys::window().unwrap().alert_with_message(&format!("Added '{}' to favorites!", group_name)).ok(); + } + favorites.set(current_favs); + } else { + web_sys::window().unwrap().alert_with_message("Please join a group first to add it to favorites!").ok(); + } + } + }) + }; + + let on_file_click = { + let file_input_ref = file_input_ref.clone(); + Callback::from(move |_: MouseEvent| { + if let Some(input) = file_input_ref.cast::() { + input.click(); + } + }) + }; + + let on_file_change = { + let file_input_ref = file_input_ref.clone(); + let group_ref = group_ref.clone(); + let name_ref = name_ref.clone(); + let tx = tx.clone(); + Callback::from(move |_: Event| { + let file_input = file_input_ref.cast::().expect("file input exists"); + let group_el = group_ref.cast::().expect("group exists"); + let name_el = name_ref.cast::().expect("name exists"); + + let group_name = group_el.value().trim().to_string(); + let user_name = name_el.value().trim().to_string(); + + if group_name.is_empty() { return; } + + if let Some(files) = file_input.files() { + if let Some(file) = files.get(0) { + let filename = file.name(); + let tx = tx.clone(); + let my_name = if user_name.is_empty() { "Me".to_string() } else { user_name }; + + let reader = web_sys::FileReader::new().unwrap(); + let reader_clone = reader.clone(); + let on_load = Closure::wrap(Box::new(move |_e: web_sys::Event| { + let result = reader_clone.result().unwrap(); + let data_url = result.as_string().unwrap(); + + if let Some(sender) = &*tx { + let _ = sender.unbounded_send(FromClient::PostFile { + group_name: Arc::new(group_name.clone()), + author: Arc::new(my_name.clone()), + filename: filename.clone(), + data: data_url, + }); + } + }) as Box); + + reader.set_onload(Some(on_load.as_ref().unchecked_ref())); + reader.read_as_data_url(&file).unwrap(); + on_load.forget(); + } + } + }) + }; + + let toggle_left = { + let left_sidebar_visible = left_sidebar_visible.clone(); + Callback::from(move |_: MouseEvent| left_sidebar_visible.set(!*left_sidebar_visible)) + }; + let toggle_right = { + let right_sidebar_visible = right_sidebar_visible.clone(); + Callback::from(move |_: MouseEvent| right_sidebar_visible.set(!*right_sidebar_visible)) + }; + + let toggle_recording = { + let is_recording = is_recording.clone(); + let tx = tx.clone(); + let group_ref = group_ref.clone(); + let my_name_state = my_name_state.clone(); + let chat_state = chat_state.clone(); + + Callback::from(move |_: MouseEvent| { + let is_recording = is_recording.clone(); + let tx = tx.clone(); + let group_ref = group_ref.clone(); + let my_name_state = my_name_state.clone(); + let chat_state = chat_state.clone(); + + let window = web_sys::window().unwrap(); + + if *is_recording { + let _ = js_sys::eval("if(window._stopRecording) window._stopRecording();"); + is_recording.set(false); + } else { + is_recording.set(true); + + let tx_clone = tx.clone(); + let group_ref_clone = group_ref.clone(); + let my_name_clone = my_name_state.clone(); + let is_recording_clone = is_recording.clone(); + let chat_state_clone = chat_state.clone(); + + let on_done = Closure::wrap(Box::new(move |data_url: String, duration: f64| { + if !data_url.is_empty() { + if let Some(sender) = &*tx_clone { + if let Some(group_el) = group_ref_clone.cast::() { + let group_name = group_el.value().trim().to_string(); + if !group_name.is_empty() { + let _ = sender.unbounded_send(FromClient::PostVoice { + group_name: Arc::new(group_name), + author: Arc::new((*my_name_clone).clone()), + duration, + data: data_url.clone(), + }); + chat_state_clone.dispatch(ChatAction::AddMessage(ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + author: (*my_name_clone).clone(), + content: MessageContent::Voice { duration, data: data_url }, + is_self: true, + is_error: false, + timestamp: chrono::Utc::now(), + reactions: Vec::new(), + reply_to: None, + })); + } + } + } + } + is_recording_clone.set(false); + }) as Box); + + let _ = js_sys::Reflect::set(&window, &"_onRecordingDone".into(), on_done.as_ref()); + on_done.forget(); + + let _ = js_sys::eval(r#" + (async function() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const rec = new MediaRecorder(stream); + const chunks = []; + const start = Date.now(); + rec.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); }; + rec.onstop = () => { + const dur = (Date.now() - start) / 1000; + const blob = new Blob(chunks, { type: 'audio/webm' }); + const reader = new FileReader(); + reader.onloadend = () => { + if (window._onRecordingDone) window._onRecordingDone(reader.result || '', dur); + stream.getTracks().forEach(t => t.stop()); + }; + reader.readAsDataURL(blob); + }; + window._stopRecording = () => { if (rec.state !== 'inactive') rec.stop(); }; + rec.start(); + } catch(e) { + alert('Microphone access denied. Please allow permissions.'); + if (window._onRecordingDone) window._onRecordingDone('', 0); + } + })(); + "#); + } + }) + }; + + let on_keypress = { + let on_send = on_send.clone(); + let is_typing = is_typing.clone(); + Callback::from(move |e: KeyboardEvent| { + if e.key() == "Enter" { + on_send.emit(MouseEvent::new("click").unwrap()); + is_typing.set(false); + } else { + is_typing.set(true); + } + }) + }; + + let on_input_change = { + let is_typing = is_typing.clone(); + Callback::from(move |e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + is_typing.set(!input.value().is_empty()); + }) + }; + + let toggle_dark_mode = { + let dark_mode = dark_mode.clone(); + Callback::from(move |_: MouseEvent| dark_mode.set(!*dark_mode)) + }; + + // --- Styles --- + + let left_w = if *left_sidebar_visible { "300px" } else { "0" }; + let right_w = if *right_sidebar_visible { "350px" } else { "0px" }; + + // Dark mode colors + let bg_color = if *dark_mode { "#1a1a1a" } else { "white" }; + let text_color = if *dark_mode { "#e0e0e0" } else { "#1a1a1a" }; + let sidebar_bg = if *dark_mode { "#2d2d2d" } else { "#f7f9fa" }; + let border_color = if *dark_mode { "#3a3a3a" } else { "#e1e4e8" }; + let input_bg = if *dark_mode { "#2d2d2d" } else { "white" }; + let hover_bg = if *dark_mode { "#3a3a3a" } else { "#edf2f7" }; + + let container_style = css!(r#" + display: grid; + grid-template-columns: ${left} 1fr ${right}; + height: 100vh; + width: 100vw; + font-family: 'Inter', sans-serif; + background-color: ${bg}; + color: ${text}; + overflow: hidden; + transition: all 0.3s ease; + position: relative; + + @media (max-width: 1200px) { + grid-template-columns: ${left} 1fr 0px; + } + @media (max-width: 800px) { + grid-template-columns: 0px 1fr 0px; + } + @media (max-width: 600px) { + font-size: 14px; + } + "#, left=left_w, right=right_w, bg=bg_color, text=text_color); + + // Sidebar Left Styles + let sidebar_left_style = css!(r#" + background-color: ${sidebar_bg}; + border-right: 1px solid ${border}; + display: flex; + flex-direction: column; + padding: 20px 0; + overflow: hidden; + transition: all 0.3s ease; + min-width: 0; + "#, sidebar_bg=sidebar_bg, border=border_color); + + let profile_small_style = css!(r#" + display: flex; + align-items: center; + padding: 0 20px; + gap: 12px; + margin-bottom: 24px; + position: relative; + "#); + + let avatar_style = css!(r#" + width: 48px; + height: 48px; + border-radius: 50%; + background-color: #ddd; + object-fit: cover; + "#); + + let search_bar_container = css!(r#" + margin: 0 20px 20px; + position: relative; + &::before { + content: "๐Ÿ”"; + position: absolute; + left: 15px; + top: 50%; + transform: translateY(-50%); + font-size: 0.8rem; + opacity: 0.5; + } + "#); + + let search_input_style = css!(r#" + width: 100%; + padding: 10px 15px 10px 40px; + border-radius: 20px; + border: 1px solid ${border}; + background-color: ${input_bg}; + color: ${text}; + font-size: 0.9rem; + outline: none; + transition: all 0.2s ease; + &:focus { border-color: #3498db; box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); } + "#, border=border_color, input_bg=input_bg, text=text_color); + + let contact_item_style = css!(r#" + display: flex; + align-items: center; + padding: 12px 20px; + gap: 15px; + cursor: pointer; + transition: all 0.2s ease; + border-radius: 8px; + margin: 0 10px; + &:hover { + background-color: ${hover}; + transform: translateX(5px); + } + &.active { background-color: #e2e8f0; } + "#, hover=hover_bg); + + // Main Chat Styles + let chat_main_style = css!(r#" + display: flex; + flex-direction: column; + background-color: ${bg}; + overflow: hidden; + transition: all 0.3s ease; + "#, bg=bg_color); + + let chat_header_style = css!(r#" + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 25px; + border-bottom: 1px solid ${border}; + transition: all 0.3s ease; + + @media (max-width: 600px) { + padding: 12px 15px; + } + "#, border=border_color); + + let chat_messages_style = css!(r#" + flex: 1; + overflow-y: auto; + padding: 20px 30px; + display: flex; + flex-direction: column; + gap: 20px; + background-color: ${bg}; + transition: all 0.3s ease; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + + @media (max-width: 600px) { + padding: 15px; + gap: 15px; + } + "#, bg=bg_color); + + let chat_footer_style = css!(r#" + padding: 15px 25px 25px; + background-color: ${footer_bg}; + transition: all 0.3s ease; + position: relative; + + @media (max-width: 600px) { + padding: 10px 15px 15px; + } + "#, footer_bg=if *dark_mode { "#2d2d2d" } else { "#e3f2fd" }); + + let input_wrapper_style = css!(r#" + background-color: ${input_bg}; + border-radius: 30px; + display: flex; + align-items: center; + padding: 5px 10px 5px 20px; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + gap: 15px; + transition: all 0.3s ease; + input { + flex: 1; + border: none; + outline: none; + padding: 10px 0; + font-size: 0.95rem; + background: transparent; + color: ${text}; + } + + @media (max-width: 600px) { + padding: 5px 8px 5px 15px; + gap: 10px; + input { + font-size: 0.9rem; + padding: 8px 0; + } + } + "#, input_bg=input_bg, text=text_color); + + let icon_btn_style = css!(r#" + background: none; + border: none; + font-size: 1.2rem; + cursor: pointer; + opacity: 0.6; + transition: opacity 0.2s; + &:hover { opacity: 1; } + "#); + + let send_circle_btn = css!(r#" + width: 45px; + height: 45px; + background-color: #0084ff; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + box-shadow: 0 4px 10px rgba(0, 132, 255, 0.3); + transition: transform 0.2s; + &:hover { transform: scale(1.05); } + &:active { transform: scale(0.95); } + + @media (max-width: 600px) { + width: 40px; + height: 40px; + font-size: 0.9rem; + } + "#); + + // Sidebar Right Styles + let sidebar_right_style = css!(r#" + background-color: ${sidebar_bg}; + border-left: 1px solid ${border}; + display: flex; + flex-direction: column; + padding: 20px; + overflow-y: auto; + transition: all 0.3s ease; + @media (max-width: 1200px) { display: none; } + "#, sidebar_bg=sidebar_bg, border=border_color); + + let profile_large_style = css!(r#" + display: flex; + flex-direction: column; + align-items: center; + margin: 30px 0; + text-align: center; + h2 { margin: 15px 0 5px; font-size: 1.2rem; } + span { opacity: 0.6; font-size: 0.85rem; } + "#); + + let action_grid_style = css!(r#" + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; + margin: 20px 0; + "#); + + let action_card_style = css!(r#" + background: ${card_bg}; + padding: 15px; + border-radius: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + border: 1px solid ${border}; + cursor: pointer; + transition: all 0.2s ease; + &:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + transform: translateY(-2px); + } + .icon { font-size: 1.5rem; color: #0084ff; } + .label { font-size: 0.8rem; font-weight: 500; color: ${text}; } + "#, card_bg=input_bg, border=border_color, text=text_color); + + let attachments_section = css!(r#" + margin-top: 30px; + "#); + + let title_row_style = css!(r#" + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + "#); + + let attachment_grid = css!(r#" + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + "#); + + let attachment_item = css!(r#" + aspect-ratio: 1; + background: white; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + border: 1px solid #edf2f7; + font-size: 0.6rem; + font-weight: 700; + color: #0084ff; + .icon { font-size: 1.2rem; } + &.pdf { background-color: #eef2ff; color: #4f46e5; } + &.video { background-color: #fff1f2; color: #e11d48; } + &.audio { background-color: #f0fdf4; color: #16a34a; } + &.image { background-color: #fefce8; color: #ca8a04; } + "#); + + let bubble_base = css!(r#" + max-width: 70%; + padding: 12px 18px; + border-radius: 20px; + font-size: 0.95rem; + line-height: 1.5; + position: relative; + animation: slideIn 0.3s ease; + word-wrap: break-word; + + @keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @media (max-width: 600px) { + max-width: 85%; + padding: 10px 14px; + font-size: 0.9rem; + } + "#); + + let my_bubble = css!(r#" + align-self: flex-end; + background-color: #0084ff; + color: white; + border-bottom-right-radius: 4px; + "#); + + let other_bubble = css!(r#" + align-self: flex-start; + background-color: ${other_bg}; + color: ${other_text}; + border-bottom-left-radius: 4px; + "#, other_bg=if *dark_mode { "#3a3a3a" } else { "#f1f3f4" }, other_text=if *dark_mode { "#e0e0e0" } else { "#1a1a1a" }); + + let connection_pill = css!(r#" + font-size: 0.75rem; + background: ${pill_bg}; + color: ${pill_text}; + padding: 4px 12px; + border-radius: 12px; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.3s ease; + .dot { + width: 8px; + height: 8px; + border-radius: 50%; + animation: pulse 2s infinite; + } + .online { background-color: #2ecc71; } + .offline { background-color: #e74c3c; } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + "#, pill_bg=if *dark_mode { "#3a3a3a" } else { "#edf2f7" }, pill_text=if *dark_mode { "#a0aec0" } else { "#4a5568" }); + + html! { +
+ + +
+
+
+ { if !*left_sidebar_visible { + html! { + + {"โ˜ฐ"}{"Menu"} + + } + } else { html! {} }} + +
+
{"General Chat"}
+
+
+ { if *connected { "Live Connection" } else { "Disconnected" } } +
+
+
+
+ + { if *dark_mode { "โ˜€๏ธ" } else { "๐ŸŒ™" } } + + { if !*right_sidebar_visible { + html! { {"โ‡ "} } + } else { html! {} }} +
+
+ +
+
+
+ {"Async History"} +
+ + { for chat_state.current_messages().iter().enumerate().map(|(idx, m)| { + let is_system = m.author == "System" || m.author == "Error" || m.is_error; + + if is_system { + let text = match &m.content { + MessageContent::Text(t) => t.clone(), + _ => "System error".to_string(), + }; + return html! { +
+ { text } +
+ }; + } + + let bubble_class = if m.is_self { + classes!(bubble_base.clone(), my_bubble.clone()) + } else { + classes!(bubble_base.clone(), other_bubble.clone()) + }; + + let formatted_time = m.timestamp.format("%H:%M").to_string(); + let my_name = (*my_name_state).clone(); + let msg_idx = idx; + + html! { +
+ +
+
+ { &m.author } + { formatted_time } +
+
+ // Show reply preview if this is a reply + { if let Some(ref reply) = m.reply_to { + html! { +
+
+ {"โ†ฉ Replying to "}{&reply.author} +
+
+ {&reply.preview} +
+
+ } + } else { html! {} }} + { match &m.content { + MessageContent::Text(text) => html! { { text } }, + MessageContent::File { filename, data } => { + if filename.ends_with(".png") || filename.ends_with(".jpg") || filename.ends_with(".jpeg") || filename.ends_with(".gif") { + html! { +
+ + { filename } +
+ } + } else { + html! { +
+ {"๐Ÿ“„"} +
+ { filename } + {"Download"} +
+
+ } + } + } + MessageContent::Voice { duration, data } => { + html! { +
+ {"๐ŸŽค"} +
+
+
+ } + } + }} +
+ + // Reactions display + { if !m.reactions.is_empty() { + html! { +
+ { for m.reactions.iter().map(|(emoji, _user)| { + html! { + + { emoji } + + } + })} +
+ } + } else { html! {} }} + + // Quick reactions + { if !m.is_error { + let tx_clone = tx.clone(); + let group_ref_clone = group_ref.clone(); + let my_name_clone = my_name.clone(); + let msg_id = m.id.clone(); + html! { +
+ { for ["โค๏ธ", "๐Ÿ‘", "๐Ÿ˜‚", "๐ŸŽ‰"].iter().map(|&emoji| { + let chat_state_inner = chat_state.clone(); + let tx_inner = tx_clone.clone(); + let group_ref_inner = group_ref_clone.clone(); + let my_name_inner = my_name_clone.clone(); + let msg_id_inner = msg_id.clone(); + let emoji_str = emoji.to_string(); + let msg_idx_inner = msg_idx; + let on_react = Callback::from(move |_: MouseEvent| { + // Update local state immediately for instant feedback + chat_state_inner.dispatch(ChatAction::AddReaction { + msg_index: msg_idx_inner, + emoji: emoji_str.clone(), + user: my_name_inner.clone(), + }); + + // Send reaction to server for broadcast to other clients + if let Some(sender) = &*tx_inner { + if let Some(group_el) = group_ref_inner.cast::() { + let group_name = group_el.value().trim().to_string(); + if !group_name.is_empty() { + let _ = sender.unbounded_send(FromClient::PostReaction { + group_name: Arc::new(group_name), + author: Arc::new(my_name_inner.clone()), + message_id: msg_id_inner.clone(), + emoji: emoji_str.clone(), + }); + } + } + } + }); + html! { + + { emoji } + + } + })} +
+ } + } else { html! {} }} +
+
+ } + })} +
+ +
+ + + { if *show_emojis { + let emoji_picker_bg = if *dark_mode { "#2d2d2d" } else { "white" }; + let emoji_border = if *dark_mode { "#3a3a3a" } else { "#e1e4e8" }; + html! { +
+ // Smileys & Emotion +
+
{"๐Ÿ˜€ Smileys"}
+
+ { for ["๐Ÿ˜€", "๐Ÿ˜ƒ", "๐Ÿ˜„", "๐Ÿ˜", "๐Ÿ˜†", "๐Ÿ˜…", "๐Ÿคฃ", "๐Ÿ˜‚", "๐Ÿ™‚", "๐Ÿ˜Š", "๐Ÿ˜‡", "๐Ÿฅฐ", "๐Ÿ˜", "๐Ÿคฉ", "๐Ÿ˜˜", "๐Ÿ˜—", "๐Ÿ˜š", "๐Ÿ˜™", "๐Ÿฅฒ", "๐Ÿ˜‹", "๐Ÿ˜›", "๐Ÿ˜œ", "๐Ÿคช", "๐Ÿ˜", "๐Ÿค‘", "๐Ÿค—", "๐Ÿคญ", "๐Ÿคซ", "๐Ÿค”", "๐Ÿค", "๐Ÿคจ", "๐Ÿ˜", "๐Ÿ˜‘", "๐Ÿ˜ถ", "๐Ÿ˜", "๐Ÿ˜’", "๐Ÿ™„", "๐Ÿ˜ฌ", "๐Ÿคฅ", "๐Ÿ˜Œ", "๐Ÿ˜”", "๐Ÿ˜ช", "๐Ÿคค", "๐Ÿ˜ด", "๐Ÿ˜ท", "๐Ÿค’", "๐Ÿค•", "๐Ÿคข", "๐Ÿคฎ", "๐Ÿฅด", "๐Ÿ˜ต", "๐Ÿคฏ", "๐Ÿค ", "๐Ÿฅณ", "๐Ÿฅธ", "๐Ÿ˜Ž", "๐Ÿค“", "๐Ÿง", "๐Ÿ˜•", "๐Ÿ˜Ÿ", "๐Ÿ™", "โ˜น๏ธ", "๐Ÿ˜ฎ", "๐Ÿ˜ฏ", "๐Ÿ˜ฒ", "๐Ÿ˜ณ", "๐Ÿฅบ", "๐Ÿ˜ฆ", "๐Ÿ˜ง", "๐Ÿ˜จ", "๐Ÿ˜ฐ", "๐Ÿ˜ฅ", "๐Ÿ˜ข", "๐Ÿ˜ญ", "๐Ÿ˜ฑ", "๐Ÿ˜–", "๐Ÿ˜ฃ", "๐Ÿ˜ž", "๐Ÿ˜“", "๐Ÿ˜ฉ", "๐Ÿ˜ซ", "๐Ÿฅฑ", "๐Ÿ˜ค", "๐Ÿ˜ก", "๐Ÿ˜ ", "๐Ÿคฌ", "๐Ÿ˜ˆ", "๐Ÿ‘ฟ", "๐Ÿ’€", "โ˜ ๏ธ", "๐Ÿ’ฉ", "๐Ÿคก", "๐Ÿ‘น", "๐Ÿ‘บ", "๐Ÿ‘ป", "๐Ÿ‘ฝ", "๐Ÿ‘พ", "๐Ÿค–"].iter().map(|&e| { + let on_click = { + let on_select_emoji = on_select_emoji.clone(); + Callback::from(move |_: MouseEvent| on_select_emoji.emit(e)) + }; + html! { { e } } + })} +
+
+ // Gestures & People +
+
{"๐Ÿ‘‹ Gestures"}
+
+ { for ["๐Ÿ‘‹", "๐Ÿคš", "๐Ÿ–๏ธ", "โœ‹", "๐Ÿ––", "๐Ÿ‘Œ", "๐ŸคŒ", "๐Ÿค", "โœŒ๏ธ", "๐Ÿคž", "๐ŸคŸ", "๐Ÿค˜", "๐Ÿค™", "๐Ÿ‘ˆ", "๐Ÿ‘‰", "๐Ÿ‘†", "๐Ÿ–•", "๐Ÿ‘‡", "โ˜๏ธ", "๐Ÿ‘", "๐Ÿ‘Ž", "โœŠ", "๐Ÿ‘Š", "๐Ÿค›", "๐Ÿคœ", "๐Ÿ‘", "๐Ÿ™Œ", "๐Ÿ‘", "๐Ÿคฒ", "๐Ÿค", "๐Ÿ™", "โœ๏ธ", "๐Ÿ’…", "๐Ÿคณ", "๐Ÿ’ช", "๐Ÿฆพ", "๐Ÿฆฟ", "๐Ÿฆต", "๐Ÿฆถ", "๐Ÿ‘‚", "๐Ÿฆป", "๐Ÿ‘ƒ", "๐Ÿง ", "๐Ÿซ€", "๐Ÿซ", "๐Ÿฆท", "๐Ÿฆด", "๐Ÿ‘€", "๐Ÿ‘๏ธ", "๐Ÿ‘…", "๐Ÿ‘„"].iter().map(|&e| { + let on_click = { + let on_select_emoji = on_select_emoji.clone(); + Callback::from(move |_: MouseEvent| on_select_emoji.emit(e)) + }; + html! { { e } } + })} +
+
+ // Hearts & Love +
+
{"โค๏ธ Hearts"}
+
+ { for ["โค๏ธ", "๐Ÿงก", "๐Ÿ’›", "๐Ÿ’š", "๐Ÿ’™", "๐Ÿ’œ", "๐Ÿ–ค", "๐Ÿค", "๐ŸคŽ", "๐Ÿ’”", "โฃ๏ธ", "๐Ÿ’•", "๐Ÿ’ž", "๐Ÿ’“", "๐Ÿ’—", "๐Ÿ’–", "๐Ÿ’˜", "๐Ÿ’", "๐Ÿ’Ÿ", "โ™ฅ๏ธ", "๐Ÿ’‹", "๐Ÿ’ฏ", "๐Ÿ’ข", "๐Ÿ’ฅ", "๐Ÿ’ซ", "๐Ÿ’ฆ", "๐Ÿ’จ", "๐Ÿ•ณ๏ธ", "๐Ÿ’ฃ", "๐Ÿ’ฌ", "๐Ÿ‘๏ธโ€๐Ÿ—จ๏ธ", "๐Ÿ—จ๏ธ", "๐Ÿ—ฏ๏ธ", "๐Ÿ’ญ", "๐Ÿ’ค"].iter().map(|&e| { + let on_click = { + let on_select_emoji = on_select_emoji.clone(); + Callback::from(move |_: MouseEvent| on_select_emoji.emit(e)) + }; + html! { { e } } + })} +
+
+ // Animals & Nature +
+
{"๐Ÿถ Animals"}
+
+ { for ["๐Ÿถ", "๐Ÿฑ", "๐Ÿญ", "๐Ÿน", "๐Ÿฐ", "๐ŸฆŠ", "๐Ÿป", "๐Ÿผ", "๐Ÿจ", "๐Ÿฏ", "๐Ÿฆ", "๐Ÿฎ", "๐Ÿท", "๐Ÿธ", "๐Ÿต", "๐Ÿ™ˆ", "๐Ÿ™‰", "๐Ÿ™Š", "๐Ÿ’", "๐Ÿ”", "๐Ÿง", "๐Ÿฆ", "๐Ÿค", "๐Ÿฃ", "๐Ÿฅ", "๐Ÿฆ†", "๐Ÿฆ…", "๐Ÿฆ‰", "๐Ÿฆ‡", "๐Ÿบ", "๐Ÿ—", "๐Ÿด", "๐Ÿฆ„", "๐Ÿ", "๐Ÿ›", "๐Ÿฆ‹", "๐ŸŒ", "๐Ÿž", "๐Ÿœ", "๐ŸฆŸ", "๐Ÿฆ—", "๐Ÿฆ‚", "๐Ÿข", "๐Ÿ", "๐ŸฆŽ", "๐Ÿฆ–", "๐Ÿฆ•", "๐Ÿ™", "๐Ÿฆ‘", "๐Ÿฆ", "๐Ÿฆž", "๐Ÿฆ€", "๐Ÿก", "๐Ÿ ", "๐ŸŸ", "๐Ÿฌ", "๐Ÿณ", "๐Ÿ‹", "๐Ÿฆˆ", "๐ŸŠ", "๐Ÿ…", "๐Ÿ†", "๐Ÿฆ“", "๐Ÿฆ", "๐Ÿฆง", "๐Ÿ˜", "๐Ÿฆ›", "๐Ÿฆ", "๐Ÿช", "๐Ÿซ", "๐Ÿฆ’", "๐Ÿฆ˜", "๐Ÿƒ", "๐Ÿ‚", "๐Ÿ„", "๐ŸŽ", "๐Ÿ–", "๐Ÿ", "๐Ÿ‘", "๐Ÿฆ™", "๐Ÿ", "๐ŸฆŒ", "๐Ÿ•", "๐Ÿฉ", "๐Ÿฆฎ", "๐Ÿˆ", "๐Ÿ“", "๐Ÿฆƒ", "๐Ÿฆš", "๐Ÿฆœ", "๐Ÿฆข", "๐Ÿฆฉ", "๐Ÿ‡", "๐Ÿฆ", "๐Ÿฆจ", "๐Ÿฆก", "๐Ÿฆฆ", "๐Ÿฆฅ", "๐Ÿ", "๐Ÿ€", "๐Ÿฟ๏ธ", "๐Ÿฆ”"].iter().map(|&e| { + let on_click = { + let on_select_emoji = on_select_emoji.clone(); + Callback::from(move |_: MouseEvent| on_select_emoji.emit(e)) + }; + html! { { e } } + })} +
+
+ // Food & Drink +
+
{"๐Ÿ• Food"}
+
+ { for ["๐Ÿ‡", "๐Ÿˆ", "๐Ÿ‰", "๐ŸŠ", "๐Ÿ‹", "๐ŸŒ", "๐Ÿ", "๐Ÿฅญ", "๐ŸŽ", "๐Ÿ", "๐Ÿ", "๐Ÿ‘", "๐Ÿ’", "๐Ÿ“", "๐Ÿซ", "๐Ÿฅ", "๐Ÿ…", "๐Ÿซ’", "๐Ÿฅฅ", "๐Ÿฅ‘", "๐Ÿ†", "๐Ÿฅ”", "๐Ÿฅ•", "๐ŸŒฝ", "๐ŸŒถ๏ธ", "๐Ÿซ‘", "๐Ÿฅ’", "๐Ÿฅฌ", "๐Ÿฅฆ", "๐Ÿง„", "๐Ÿง…", "๐Ÿ„", "๐Ÿฅœ", "๐ŸŒฐ", "๐Ÿž", "๐Ÿฅ", "๐Ÿฅ–", "๐Ÿซ“", "๐Ÿฅจ", "๐Ÿฅฏ", "๐Ÿฅž", "๐Ÿง‡", "๐Ÿง€", "๐Ÿ–", "๐Ÿ—", "๐Ÿฅฉ", "๐Ÿฅ“", "๐Ÿ”", "๐ŸŸ", "๐Ÿ•", "๐ŸŒญ", "๐Ÿฅช", "๐ŸŒฎ", "๐ŸŒฏ", "๐Ÿซ”", "๐Ÿฅ™", "๐Ÿง†", "๐Ÿฅš", "๐Ÿณ", "๐Ÿฅ˜", "๐Ÿฒ", "๐Ÿซ•", "๐Ÿฅฃ", "๐Ÿฅ—", "๐Ÿฟ", "๐Ÿงˆ", "๐Ÿง‚", "๐Ÿฅซ", "๐Ÿฑ", "๐Ÿ˜", "๐Ÿ™", "๐Ÿš", "๐Ÿ›", "๐Ÿœ", "๐Ÿ", "๐Ÿ ", "๐Ÿข", "๐Ÿฃ", "๐Ÿค", "๐Ÿฅ", "๐Ÿฅฎ", "๐Ÿก", "๐ŸฅŸ", "๐Ÿฅ ", "๐Ÿฅก", "๐Ÿฆ€", "๐Ÿฆž", "๐Ÿฆ", "๐Ÿฆ‘", "๐Ÿฆช", "๐Ÿฆ", "๐Ÿง", "๐Ÿจ", "๐Ÿฉ", "๐Ÿช", "๐ŸŽ‚", "๐Ÿฐ", "๐Ÿง", "๐Ÿฅง", "๐Ÿซ", "๐Ÿฌ", "๐Ÿญ", "๐Ÿฎ", "๐Ÿฏ", "๐Ÿผ", "๐Ÿฅ›", "โ˜•", "๐Ÿซ–", "๐Ÿต", "๐Ÿถ", "๐Ÿพ", "๐Ÿท", "๐Ÿธ", "๐Ÿน", "๐Ÿบ", "๐Ÿป", "๐Ÿฅ‚", "๐Ÿฅƒ", "๐Ÿฅค", "๐Ÿง‹", "๐Ÿงƒ", "๐Ÿง‰", "๐ŸงŠ"].iter().map(|&e| { + let on_click = { + let on_select_emoji = on_select_emoji.clone(); + Callback::from(move |_: MouseEvent| on_select_emoji.emit(e)) + }; + html! { { e } } + })} +
+
+ // Activities & Celebrations +
+
{"๐ŸŽ‰ Activities"}
+
+ { for ["โšฝ", "๐Ÿ€", "๐Ÿˆ", "โšพ", "๐ŸฅŽ", "๐ŸŽพ", "๐Ÿ", "๐Ÿ‰", "๐Ÿฅ", "๐ŸŽฑ", "๐Ÿช€", "๐Ÿ“", "๐Ÿธ", "๐Ÿ’", "๐Ÿ‘", "๐Ÿฅ", "๐Ÿ", "๐Ÿชƒ", "๐Ÿฅ…", "โ›ณ", "๐Ÿช", "๐Ÿน", "๐ŸŽฃ", "๐Ÿคฟ", "๐ŸฅŠ", "๐Ÿฅ‹", "๐ŸŽฝ", "๐Ÿ›น", "๐Ÿ›ท", "โ›ธ๏ธ", "๐ŸฅŒ", "๐ŸŽฟ", "โ›ท๏ธ", "๐Ÿ‚", "๐Ÿช‚", "๐ŸŽช", "๐ŸŽญ", "๐ŸŽจ", "๐ŸŽฌ", "๐ŸŽค", "๐ŸŽง", "๐ŸŽผ", "๐ŸŽน", "๐Ÿฅ", "๐ŸŽท", "๐ŸŽบ", "๐ŸŽธ", "๐Ÿช•", "๐ŸŽป", "๐ŸŽฒ", "โ™Ÿ๏ธ", "๐ŸŽฏ", "๐ŸŽณ", "๐ŸŽฎ", "๐ŸŽฐ", "๐Ÿงฉ", "๐ŸŽ", "๐ŸŽ€", "๐ŸŽŠ", "๐ŸŽ‰", "๐ŸŽˆ", "๐Ÿช…", "๐Ÿช†", "๐Ÿงจ", "โœจ", "๐ŸŽ‡", "๐ŸŽ†", "๐ŸŒŸ", "โญ", "๐Ÿ”ฅ", "๐Ÿ’ซ", "๐ŸŒˆ", "โ˜€๏ธ", "๐ŸŒค๏ธ", "โ›…", "๐ŸŒฅ๏ธ", "โ˜๏ธ", "๐ŸŒฆ๏ธ", "๐ŸŒง๏ธ", "โ›ˆ๏ธ", "๐ŸŒฉ๏ธ", "๐ŸŒจ๏ธ", "โ„๏ธ", "โ˜ƒ๏ธ", "โ›„", "๐ŸŒฌ๏ธ", "๐Ÿ’จ", "๐ŸŒช๏ธ", "๐ŸŒซ๏ธ", "๐ŸŒŠ", "๐Ÿ’ง", "๐Ÿ’ฆ", "โ˜”", "๐Ÿ”†", "๐Ÿ”…"].iter().map(|&e| { + let on_click = { + let on_select_emoji = on_select_emoji.clone(); + Callback::from(move |_: MouseEvent| on_select_emoji.emit(e)) + }; + html! { { e } } + })} +
+
+ // Objects & Symbols +
+
{"๐Ÿ’ป Objects"}
+
+ { for ["โŒš", "๐Ÿ“ฑ", "๐Ÿ’ป", "โŒจ๏ธ", "๐Ÿ–ฅ๏ธ", "๐Ÿ–จ๏ธ", "๐Ÿ–ฑ๏ธ", "๐Ÿ–ฒ๏ธ", "๐Ÿ’ฝ", "๐Ÿ’พ", "๐Ÿ’ฟ", "๐Ÿ“€", "๐Ÿ“ผ", "๐Ÿ“ท", "๐Ÿ“ธ", "๐Ÿ“น", "๐ŸŽฅ", "๐Ÿ“ฝ๏ธ", "๐ŸŽž๏ธ", "๐Ÿ“ž", "โ˜Ž๏ธ", "๐Ÿ“Ÿ", "๐Ÿ“ ", "๐Ÿ“บ", "๐Ÿ“ป", "๐ŸŽ™๏ธ", "๐ŸŽš๏ธ", "๐ŸŽ›๏ธ", "๐Ÿงญ", "โฑ๏ธ", "โฒ๏ธ", "โฐ", "๐Ÿ•ฐ๏ธ", "โŒ›", "โณ", "๐Ÿ“ก", "๐Ÿ”‹", "๐Ÿ”Œ", "๐Ÿ’ก", "๐Ÿ”ฆ", "๐Ÿ•ฏ๏ธ", "๐Ÿช”", "๐Ÿงฏ", "๐Ÿ›ข๏ธ", "๐Ÿ’ธ", "๐Ÿ’ต", "๐Ÿ’ด", "๐Ÿ’ถ", "๐Ÿ’ท", "๐Ÿช™", "๐Ÿ’ฐ", "๐Ÿ’ณ", "๐Ÿ’Ž", "โš–๏ธ", "๐Ÿชœ", "๐Ÿงฐ", "๐Ÿ”ง", "๐Ÿ”จ", "โš’๏ธ", "๐Ÿ› ๏ธ", "โ›๏ธ", "๐Ÿช“", "๐Ÿ”ฉ", "โš™๏ธ", "๐Ÿชค", "๐Ÿงฑ", "โ›“๏ธ", "๐Ÿงฒ", "๐Ÿ”ซ", "๐Ÿ’ฃ", "๐Ÿงจ", "๐Ÿชš", "๐Ÿ”ช", "๐Ÿ—ก๏ธ", "โš”๏ธ", "๐Ÿ›ก๏ธ", "๐Ÿšฌ", "โšฐ๏ธ", "๐Ÿชฆ", "โšฑ๏ธ", "๐Ÿบ", "๐Ÿ”ฎ", "๐Ÿ“ฟ", "๐Ÿงฟ", "๐Ÿ’ˆ", "โš—๏ธ", "๐Ÿ”ญ", "๐Ÿ”ฌ", "๐Ÿ•ณ๏ธ", "๐Ÿฉน", "๐Ÿฉบ", "๐Ÿ’Š", "๐Ÿ’‰", "๐Ÿฉธ", "๐Ÿงฌ", "๐Ÿฆ ", "๐Ÿงซ", "๐Ÿงช", "๐ŸŒก๏ธ", "๐Ÿงน", "๐Ÿช ", "๐Ÿงบ", "๐Ÿงป", "๐Ÿšฝ", "๐Ÿšฐ", "๐Ÿšฟ", "๐Ÿ›", "๐Ÿ›€", "๐Ÿงผ", "๐Ÿชฅ", "๐Ÿช’", "๐Ÿงฝ", "๐Ÿชฃ", "๐Ÿงด", "๐Ÿ›Ž๏ธ", "๐Ÿ”‘", "๐Ÿ—๏ธ", "๐Ÿšช", "๐Ÿช‘", "๐Ÿ›‹๏ธ", "๐Ÿ›๏ธ", "๐Ÿ›Œ", "๐Ÿงธ", "๐Ÿ–ผ๏ธ", "๐Ÿชž", "๐ŸชŸ", "๐Ÿ›’", "๐ŸŽ", "๐ŸŽˆ", "๐ŸŽ", "๐ŸŽ€", "๐Ÿช„", "๐Ÿงง", "๐ŸŽ", "๐ŸŽ‘", "๐Ÿงง", "โœ‰๏ธ", "๐Ÿ“ฉ", "๐Ÿ“จ", "๐Ÿ“ง", "๐Ÿ’Œ", "๐Ÿ“ฅ", "๐Ÿ“ค", "๐Ÿ“ฆ", "๐Ÿท๏ธ", "๐Ÿ“ช", "๐Ÿ“ซ", "๐Ÿ“ฌ", "๐Ÿ“ญ", "๐Ÿ“ฎ", "๐Ÿ“ฏ", "๐Ÿ“œ", "๐Ÿ“ƒ", "๐Ÿ“„", "๐Ÿ“‘", "๐Ÿงพ", "๐Ÿ“Š", "๐Ÿ“ˆ", "๐Ÿ“‰", "๐Ÿ—’๏ธ", "๐Ÿ—“๏ธ", "๐Ÿ“†", "๐Ÿ“…", "๐Ÿ—‘๏ธ", "๐Ÿ“‡", "๐Ÿ—ƒ๏ธ", "๐Ÿ—ณ๏ธ", "๐Ÿ—„๏ธ", "๐Ÿ“‹", "๐Ÿ“", "๐Ÿ“‚", "๐Ÿ—‚๏ธ", "๐Ÿ—ž๏ธ", "๐Ÿ“ฐ", "๐Ÿ““", "๐Ÿ“”", "๐Ÿ“’", "๐Ÿ“•", "๐Ÿ“—", "๐Ÿ“˜", "๐Ÿ“™", "๐Ÿ“š", "๐Ÿ“–", "๐Ÿ”–", "๐Ÿงท", "๐Ÿ”—", "๐Ÿ“Ž", "๐Ÿ–‡๏ธ", "๐Ÿ“", "๐Ÿ“", "๐Ÿงฎ", "๐Ÿ“Œ", "๐Ÿ“", "โœ‚๏ธ", "๐Ÿ–Š๏ธ", "๐Ÿ–‹๏ธ", "โœ’๏ธ", "๐Ÿ–Œ๏ธ", "๐Ÿ–๏ธ", "๐Ÿ“", "โœ๏ธ", "๐Ÿ”", "๐Ÿ”Ž", "๐Ÿ”", "๐Ÿ”", "๐Ÿ”’", "๐Ÿ”“", "โœ…", "โŽ", "โœ”๏ธ", "โŒ", "โ“", "โ”", "โ•", "โ—", "ใ€ฐ๏ธ", "โž•", "โž–", "โž—", "โœ–๏ธ", "๐Ÿ’ฒ", "๐Ÿ’ฑ", "โ„ข๏ธ", "ยฉ๏ธ", "ยฎ๏ธ", "๐Ÿ”š", "๐Ÿ”™", "๐Ÿ”›", "๐Ÿ”", "๐Ÿ”œ", "โ˜‘๏ธ", "๐Ÿ”˜", "๐Ÿ”ด", "๐ŸŸ ", "๐ŸŸก", "๐ŸŸข", "๐Ÿ”ต", "๐ŸŸฃ", "โšซ", "โšช", "๐ŸŸค", "๐Ÿ”บ", "๐Ÿ”ป", "๐Ÿ”ธ", "๐Ÿ”น", "๐Ÿ”ถ", "๐Ÿ”ท", "๐Ÿ”ณ", "๐Ÿ”ฒ", "โ–ช๏ธ", "โ–ซ๏ธ", "โ—พ", "โ—ฝ", "โ—ผ๏ธ", "โ—ป๏ธ", "๐ŸŸฅ", "๐ŸŸง", "๐ŸŸจ", "๐ŸŸฉ", "๐ŸŸฆ", "๐ŸŸช", "โฌ›", "โฌœ", "๐ŸŸซ"].iter().map(|&e| { + let on_click = { + let on_select_emoji = on_select_emoji.clone(); + Callback::from(move |_: MouseEvent| on_select_emoji.emit(e)) + }; + html! { { e } } + })} +
+
+
+ } + } else { html! {} }} + +
+
+ {"๐Ÿ“Ž"} + {"๐Ÿ˜Š"} +
+ +
+ {"๐Ÿ“ท"} + + { if *is_recording { "โน๏ธ" } else { "๐ŸŽ™๏ธ" } } + + { if *is_recording { + html! { + + {"Recording... Click to stop & send"} + + } + } else { html! {} }} +
+
+ + + // Typing indicator + { if *is_typing && chat_state.typing_users.len() > 0 { + let typing_bg = if *dark_mode { "#3a3a3a" } else { "#f1f3f4" }; + let typing_text = if *dark_mode { "#a0aec0" } else { "#6b7280" }; + html! { +
+ {"Someone is typing"} + + +
+ } + } else { html! {} }} +
+
+ + +
+ } +} diff --git a/web-ui/src/components/audio.rs b/web-ui/src/components/audio.rs new file mode 100644 index 0000000..0883d01 --- /dev/null +++ b/web-ui/src/components/audio.rs @@ -0,0 +1,97 @@ +use wasm_bindgen::prelude::*; +use web_sys::{AudioContext, OscillatorType}; + +/// Play a notification sound using Web Audio API +pub fn play_notification_sound() { + if let Err(e) = play_notification_internal() { + web_sys::console::log_1(&format!("Audio error: {:?}", e).into()); + } +} + +fn play_notification_internal() -> Result<(), JsValue> { + let context = AudioContext::new()?; + + // Create oscillator for the tone + let oscillator = context.create_oscillator()?; + let gain = context.create_gain()?; + + // Connect oscillator -> gain -> destination + oscillator.connect_with_audio_node(&gain)?; + gain.connect_with_audio_node(&context.destination())?; + + // Configure the sound - pleasant notification tone + oscillator.set_type(OscillatorType::Sine); + oscillator.frequency().set_value(880.0); // A5 note + + // Envelope: quick fade in, sustain, fade out + let now = context.current_time(); + gain.gain().set_value_at_time(0.0, now)?; + gain.gain().linear_ramp_to_value_at_time(0.3, now + 0.05)?; // Fade in + gain.gain().linear_ramp_to_value_at_time(0.3, now + 0.1)?; // Sustain + gain.gain().linear_ramp_to_value_at_time(0.0, now + 0.3)?; // Fade out + + // Play the sound + oscillator.start()?; + oscillator.stop_with_when(now + 0.3)?; + + Ok(()) +} + +/// Play a simple click sound for UI feedback +pub fn play_click_sound() { + if let Err(_) = play_click_internal() { + // Silently fail for click sounds + } +} + +fn play_click_internal() -> Result<(), JsValue> { + let context = AudioContext::new()?; + let oscillator = context.create_oscillator()?; + let gain = context.create_gain()?; + + oscillator.connect_with_audio_node(&gain)?; + gain.connect_with_audio_node(&context.destination())?; + + oscillator.set_type(OscillatorType::Sine); + oscillator.frequency().set_value(1200.0); + + let now = context.current_time(); + gain.gain().set_value_at_time(0.1, now)?; + gain.gain().linear_ramp_to_value_at_time(0.0, now + 0.05)?; + + oscillator.start()?; + oscillator.stop_with_when(now + 0.05)?; + + Ok(()) +} + +/// Play a send message sound +pub fn play_send_sound() { + if let Err(_) = play_send_internal() { + // Silently fail + } +} + +fn play_send_internal() -> Result<(), JsValue> { + let context = AudioContext::new()?; + let oscillator = context.create_oscillator()?; + let gain = context.create_gain()?; + + oscillator.connect_with_audio_node(&gain)?; + gain.connect_with_audio_node(&context.destination())?; + + oscillator.set_type(OscillatorType::Sine); + + // Rising tone for "sent" feeling + let now = context.current_time(); + oscillator.frequency().set_value_at_time(600.0, now)?; + oscillator.frequency().linear_ramp_to_value_at_time(900.0, now + 0.1)?; + + gain.gain().set_value_at_time(0.15, now)?; + gain.gain().linear_ramp_to_value_at_time(0.0, now + 0.15)?; + + oscillator.start()?; + oscillator.stop_with_when(now + 0.15)?; + + Ok(()) +} diff --git a/web-ui/src/components/mod.rs b/web-ui/src/components/mod.rs new file mode 100644 index 0000000..d36be96 --- /dev/null +++ b/web-ui/src/components/mod.rs @@ -0,0 +1,2 @@ +pub mod app; +pub mod audio; \ No newline at end of file diff --git a/web-ui/src/main.rs b/web-ui/src/main.rs new file mode 100644 index 0000000..df79e12 --- /dev/null +++ b/web-ui/src/main.rs @@ -0,0 +1,15 @@ +mod components; +mod services; +mod styles; +mod types; + +use components::app::App; + +fn main() { + // Initialize logger for debugging + wasm_logger::init(wasm_logger::Config::new(log::Level::Debug)); + console_error_panic_hook::set_once(); + + // Mount the app to the DOM + yew::Renderer::::new().render(); +} diff --git a/web-ui/src/mod.rs b/web-ui/src/mod.rs new file mode 100644 index 0000000..20cf8f7 --- /dev/null +++ b/web-ui/src/mod.rs @@ -0,0 +1 @@ +pub mod components; \ No newline at end of file diff --git a/web-ui/src/services/mod.rs b/web-ui/src/services/mod.rs new file mode 100644 index 0000000..1be06cf --- /dev/null +++ b/web-ui/src/services/mod.rs @@ -0,0 +1,2 @@ +// Services module + diff --git a/web-ui/src/styles/mod.rs b/web-ui/src/styles/mod.rs new file mode 100644 index 0000000..65ae9bd --- /dev/null +++ b/web-ui/src/styles/mod.rs @@ -0,0 +1,2 @@ +// Styles module + diff --git a/web-ui/src/types.rs b/web-ui/src/types.rs new file mode 100644 index 0000000..3a5d3ee --- /dev/null +++ b/web-ui/src/types.rs @@ -0,0 +1,3 @@ +// Types module +// Re-export common types if needed, or define frontend-specific types here. +