{move || { - let sc2 = sc.clone(); - let pid = peer_id; if show_add_server.get() { - let add_client = sc2.clone(); view! {
-

"Add a Server"

}.into_any() } else if show_server_settings.get() { - let sc3 = sc2.clone(); - view! { }.into_any() + view! { }.into_any() } else if show_settings.get() { - view! { }.into_any() } else { - let fc2 = fc.clone(); - let pc2 = pc.clone(); let send2 = send.clone(); let edit_send2 = edit_send.clone(); let del_msg2 = del_msg.clone(); let react2 = react.clone(); - let tc2 = tc.clone(); - let on_typing_cb = Callback::new(move |_: ()| { - tc2.borrow_mut().send_typing(); - }); - let on_pin_cb = Callback::new(move |msg: DisplayMessage| { - let ch = current_channel.get_untracked(); - let mut c = pc2.borrow_mut(); - if c.is_pinned(&ch, &msg.id) { - let _ = c.unpin_message(&ch, &msg.id); - } else { - let _ = c.pin_message(&ch, &msg.id); - } - set_pinned_messages.set(c.pinned_messages(&ch)); - let mut labels = HashMap::new(); - for m in c.messages(&ch) { - let label = if c.is_pinned(&ch, &m.id) { "Unpin" } else { "Pin" }; - labels.insert(m.id.clone(), label.to_string()); - } - set_pin_labels.set(labels); - }); + let on_typing_cb = { + let h = handle_ty.clone(); + Callback::new(move |_: ()| { + h.send_typing(); + }) + }; + let on_pin_cb = { + let pin_handler = pin.clone(); + Callback::new(move |msg: DisplayMessage| { + pin_handler(msg); + }) + }; view! {
{move || { if show_pinned.get() { @@ -674,9 +431,9 @@ pub fn App() -> impl IntoView { "document.getElementById('msg-{}')?.scrollIntoView({{behavior:'smooth',block:'center'}})", msg_id.replace('\'', "") )); - set_show_pinned.set(false); + write.ui.set_show_pinned.set(false); } - on_close=move |_| set_show_pinned.set(false) + on_close=move |_| write.ui.set_show_pinned.set(false) /> }) } else { @@ -688,12 +445,11 @@ pub fn App() -> impl IntoView { loading=Signal::from(loading) local_display_name={let s: Signal = Signal::from(display_name); s} on_message_click=Callback::new(move |msg: DisplayMessage| { - set_replying_to.set(Some(msg)); - // Auto-focus the input field so keyboard opens on mobile. + write.chat.set_replying_to.set(Some(msg)); let _ = js_sys::eval("setTimeout(function(){var i=document.querySelector('.input-area input,.input-area textarea');if(i)i.focus();},50)"); }) on_edit=Callback::new(move |msg: DisplayMessage| { - set_editing.set(Some(msg)); + write.chat.set_editing.set(Some(msg)); let _ = js_sys::eval("setTimeout(function(){var i=document.querySelector('.input-area input,.input-area textarea');if(i)i.focus();},50)"); }) on_delete=Callback::new(del_msg2) @@ -703,7 +459,12 @@ pub fn App() -> impl IntoView { />
{move || { - let names = typing_names.get(); + let ch = current_channel.get(); + let views = channel_views.get(); + let names = views + .get(&ch) + .map(|v| v.typing.clone()) + .unwrap_or_default(); match names.len() { 0 => String::new(), 1 => format!("{} is typing...", names[0]), @@ -715,19 +476,18 @@ pub fn App() -> impl IntoView {
@@ -739,12 +499,11 @@ pub fn App() -> impl IntoView {
@@ -760,7 +519,7 @@ pub fn App() -> impl IntoView { /// The `RefCell` borrow is held across await but this is safe on /// single-threaded WASM where there is no preemption. #[allow(clippy::await_holding_refcell_ref)] -async fn handle_voice_create_offer(vm: VoiceManagerHandle, peer_id: String) { +pub async fn handle_voice_create_offer(vm: VoiceManagerHandle, peer_id: String) { let mut mgr = vm.borrow_mut(); let _ = mgr.create_offer(&peer_id).await; } @@ -770,7 +529,7 @@ async fn handle_voice_create_offer(vm: VoiceManagerHandle, peer_id: String) { /// The `RefCell` borrow is held across await but this is safe on /// single-threaded WASM where there is no preemption. #[allow(clippy::await_holding_refcell_ref)] -async fn handle_voice_offer(vm: VoiceManagerHandle, from: String, sdp: String) { +pub async fn handle_voice_offer(vm: VoiceManagerHandle, from: String, sdp: String) { let mut mgr = vm.borrow_mut(); let _ = mgr.handle_offer(&from, &sdp).await; } @@ -780,23 +539,7 @@ async fn handle_voice_offer(vm: VoiceManagerHandle, from: String, sdp: String) { /// The `RefCell` borrow is held across await but this is safe on /// single-threaded WASM where there is no preemption. #[allow(clippy::await_holding_refcell_ref)] -async fn handle_voice_answer(vm: VoiceManagerHandle, from: String, sdp: String) { +pub async fn handle_voice_answer(vm: VoiceManagerHandle, from: String, sdp: String) { let mgr = vm.borrow(); let _ = mgr.handle_answer(&from, &sdp).await; } - -/// Extract roles from the client's event-sourced state as a list of -/// `(role_id, role_name, permission_strings)` tuples for reactive signals. -fn extract_roles(client: &willow_client::Client) -> Vec<(String, String, Vec)> { - let es = &client.state().event_state; - let mut entries: Vec<(String, String, Vec)> = es - .roles - .values() - .map(|role| { - let perms: Vec = role.permissions.iter().cloned().collect(); - (role.id.clone(), role.name.clone(), perms) - }) - .collect(); - entries.sort_by(|a, b| a.1.cmp(&b.1)); - entries -} diff --git a/crates/web/src/components/add_server.rs b/crates/web/src/components/add_server.rs index 19eeb41f..656a1c91 100644 --- a/crates/web/src/components/add_server.rs +++ b/crates/web/src/components/add_server.rs @@ -3,15 +3,14 @@ use std::rc::Rc; use leptos::prelude::*; use send_wrapper::SendWrapper; -use crate::app::ClientHandle; +use crate::app::WebClientHandle; /// Panel for creating a new server or joining an existing one via invite code. /// Shown when the user clicks the "+" button in the server rail. #[component] -pub fn AddServerPanel( - client: ClientHandle, - on_done: impl Fn(()) + Send + Clone + 'static, -) -> impl IntoView { +pub fn AddServerPanel(on_done: impl Fn(()) + Send + Clone + 'static) -> impl IntoView { + let handle = use_context::().unwrap(); + // Create server state. let (server_name, set_server_name) = signal(String::new()); let (create_display_name, set_create_display_name) = signal(String::new()); @@ -23,13 +22,10 @@ pub fn AddServerPanel( let (join_profile_name, set_join_profile_name) = signal(String::new()); let (validated_code, set_validated_code) = signal(String::new()); - { - let c = client.borrow(); - set_join_profile_name.set(c.display_name()); - } + set_join_profile_name.set(handle.display_name()); // Create handler. - let client_create = client.clone(); + let handle_create = handle.clone(); let on_done_create = on_done.clone(); let on_create = move |_| { let name = server_name.get_untracked(); @@ -37,14 +33,12 @@ pub fn AddServerPanel( set_status_msg.set("Please enter a server name.".to_string()); return; } - let mut c = client_create.borrow_mut(); - match c.create_server(name.trim()) { + match handle_create.create_server(name.trim()) { Ok(_) => { let dn = create_display_name.get_untracked(); if !dn.trim().is_empty() { - let _ = c.set_server_display_name(dn.trim()); + let _ = handle_create.set_server_display_name(dn.trim()); } - drop(c); on_done_create(()); } Err(e) => set_status_msg.set(format!("Error: {e}")), @@ -64,7 +58,7 @@ pub fn AddServerPanel( }; // Join step 2. - let client_join = SendWrapper::new(Rc::new(client.clone())); + let handle_join = SendWrapper::new(Rc::new(handle.clone())); let on_done_rc: SendWrapper> = SendWrapper::new(Rc::new(on_done) as Rc); @@ -103,20 +97,18 @@ pub fn AddServerPanel(

"Join a Server"

{move || { if join_step.get() { - let cj = client_join.clone(); + let hj = handle_join.clone(); let done_cb = on_done_rc.clone(); let confirm = move |_: web_sys::MouseEvent| { let code = validated_code.get_untracked(); - let mut c = cj.borrow_mut(); - match c.accept_invite(&code) { + match hj.accept_invite(&code) { Ok(()) => { let name = join_profile_name.get_untracked(); if !name.trim().is_empty() { - let _ = c.set_server_display_name(name.trim()); + let _ = hj.set_server_display_name(name.trim()); } set_join_code.set(String::new()); set_join_step.set(false); - drop(c); (done_cb)(()); } Err(e) => { diff --git a/crates/web/src/components/file_share.rs b/crates/web/src/components/file_share.rs index 8e1f1223..13b77758 100644 --- a/crates/web/src/components/file_share.rs +++ b/crates/web/src/components/file_share.rs @@ -2,7 +2,7 @@ use leptos::prelude::*; use wasm_bindgen::closure::Closure; use wasm_bindgen::JsCast; -use crate::app::ClientHandle; +use crate::app::WebClientHandle; /// Maximum inline file size (256 KB). const MAX_FILE_SIZE: u64 = 256 * 1024; @@ -12,7 +12,9 @@ const MAX_FILE_SIZE: u64 = 256 * 1024; /// /// Files larger than 256 KB are rejected with a browser alert. #[component] -pub fn FileShareButton(client: ClientHandle, channel: ReadSignal) -> impl IntoView { +pub fn FileShareButton(channel: ReadSignal) -> impl IntoView { + let handle = use_context::().unwrap(); + // Create a hidden file input and trigger it on button click. let input_ref = NodeRef::::new(); @@ -25,7 +27,7 @@ pub fn FileShareButton(client: ClientHandle, channel: ReadSignal) -> imp } }; - let client_change = client.clone(); + let handle_change = handle.clone(); let on_change = move |_ev: web_sys::Event| { let Some(input) = input_ref.get() else { return; @@ -48,7 +50,7 @@ pub fn FileShareButton(client: ClientHandle, channel: ReadSignal) -> imp let filename = file.name(); let ch = channel.get_untracked(); - let client_inner = client_change.clone(); + let handle_inner = handle_change.clone(); let reader = web_sys::FileReader::new().unwrap(); let reader_clone = reader.clone(); @@ -59,8 +61,7 @@ pub fn FileShareButton(client: ClientHandle, channel: ReadSignal) -> imp let uint8 = js_sys::Uint8Array::new(&array_buf); let data = uint8.to_vec(); - let mut c = client_inner.borrow_mut(); - if let Err(e) = c.share_file_inline(&ch, &filename, &data) { + if let Err(e) = handle_inner.share_file_inline(&ch, &filename, &data) { let window = web_sys::window().unwrap(); let _ = window.alert_with_message(&format!("Failed to share file: {e}")); } diff --git a/crates/web/src/components/member_list.rs b/crates/web/src/components/member_list.rs index b7e20647..5af25af1 100644 --- a/crates/web/src/components/member_list.rs +++ b/crates/web/src/components/member_list.rs @@ -1,15 +1,16 @@ use leptos::prelude::*; -use crate::app::ClientHandle; +use crate::app::WebClientHandle; /// Right sidebar showing connected peers with trust/kick actions. /// Accepts `(peer_id, display_name)` tuples so names update reactively. #[component] pub fn MemberList( peers: ReadSignal>, - client: ClientHandle, peer_id: ReadSignal, ) -> impl IntoView { + let handle = use_context::().unwrap(); + view! {

"Members"

@@ -25,10 +26,10 @@ pub fn MemberList( let pid_untrust = pid.clone(); let pid_kick = pid.clone(); let pid_self = pid.clone(); - let client_badge = client.clone(); - let client_trust = client.clone(); - let client_untrust = client.clone(); - let client_kick = client.clone(); + let handle_badge = handle.clone(); + let handle_trust = handle.clone(); + let handle_untrust = handle.clone(); + let handle_kick = handle.clone(); view! {
@@ -41,15 +42,12 @@ pub fn MemberList( { let pb = pid_badge.clone(); - let cb = client_badge.clone(); + let hb = handle_badge.clone(); move || { - let c = cb.borrow(); - let owner = c.state().event_state.owner.clone(); + let owner = hb.server_owner(); if pb == owner { Some(view! { "Owner" }) - } else if c.state().event_state - .has_permission(&pb, &willow_client::willow_state::Permission::Administrator) - { + } else if hb.has_permission(&pb, &willow_client::willow_state::Permission::Administrator) { Some(view! { "Trusted" }) } else { None @@ -63,29 +61,27 @@ pub fn MemberList( move || peer_id.get() == p }; let pt = pid_trust.clone(); - let ct = client_trust.clone(); + let ht = handle_trust.clone(); let pu = pid_untrust.clone(); - let cu = client_untrust.clone(); + let hu = handle_untrust.clone(); let pk = pid_kick.clone(); - let ck = client_kick.clone(); + let hk = handle_kick.clone(); + let hb2 = handle_badge.clone(); move || { - let is_owner = { - let c = client_badge.borrow(); - c.state().event_state.owner == peer_id.get_untracked() - }; + let is_owner = hb2.server_owner() == peer_id.get_untracked(); if is_self() || !is_owner { None } else { let pt = pt.clone(); - let ct = ct.clone(); + let ht = ht.clone(); let pu = pu.clone(); - let cu = cu.clone(); + let hu = hu.clone(); let pk = pk.clone(); - let ck = ck.clone(); + let hk = hk.clone(); Some(view! { - - - + + + }) } } diff --git a/crates/web/src/components/roles.rs b/crates/web/src/components/roles.rs index 6d72f7b0..0ec14e2f 100644 --- a/crates/web/src/components/roles.rs +++ b/crates/web/src/components/roles.rs @@ -1,6 +1,6 @@ use leptos::prelude::*; -use crate::app::ClientHandle; +use crate::app::WebClientHandle; /// List of all permission names that can be toggled on a role. const PERMISSION_NAMES: &[&str] = &[ @@ -23,30 +23,29 @@ type RoleEntry = (String, String, Vec); /// permission toggles, assign). Non-owners see a read-only list. #[component] pub fn RoleManager( - client: ClientHandle, peer_id: ReadSignal, #[prop(into)] roles: Signal>, ) -> impl IntoView { + let handle = use_context::().unwrap(); + let (creating, set_creating) = signal(false); let (new_name, set_new_name) = signal(String::new()); let (assign_peer, set_assign_peer) = signal(String::new()); // Determine if the local user is the server owner. - let client_owner = client.clone(); + let handle_owner = handle.clone(); let is_owner = move || { - let c = client_owner.borrow(); let pid = peer_id.get(); - c.state().event_state.owner == pid + handle_owner.server_owner() == pid }; // Create role handler. - let client_create = client.clone(); + let handle_create = handle.clone(); let on_create_submit = move || { let name = new_name.get_untracked(); let name = name.trim().to_string(); if !name.is_empty() { - let mut c = client_create.borrow_mut(); - let _ = c.create_role(&name); + let _ = handle_create.create_role(&name); } set_new_name.set(String::new()); set_creating.set(false); @@ -127,9 +126,9 @@ pub fn RoleManager( let role_id_delete = role_id.clone(); let role_id_perms = role_id.clone(); let role_id_assign = role_id.clone(); - let client_delete = client.clone(); - let client_perm = client.clone(); - let client_assign = client.clone(); + let handle_delete = handle.clone(); + let handle_perm = handle.clone(); + let handle_assign = handle.clone(); let owner_check = is_owner.clone(); view! {
@@ -137,19 +136,18 @@ pub fn RoleManager( {role_name} { let oc = owner_check.clone(); - let cd = client_delete.clone(); + let hd = handle_delete.clone(); let rid = role_id_delete.clone(); move || { if oc() { - let cd = cd.clone(); + let hd = hd.clone(); let rid = rid.clone(); Some(view! {
- +
} diff --git a/crates/web/src/event_processing.rs b/crates/web/src/event_processing.rs new file mode 100644 index 00000000..d382d113 --- /dev/null +++ b/crates/web/src/event_processing.rs @@ -0,0 +1,221 @@ +//! Event processing logic extracted from the old poll loop. +//! +//! [`process_event_batch`] takes a batch of [`ClientEvent`]s and updates the +//! reactive signals via [`AppWriteSignals`]. It uses the same flag-based +//! approach (`needs_msg_refresh`, `needs_peer_refresh`, `needs_channel_refresh`) +//! as the old `set_interval` poll loop. + +use std::collections::HashMap; + +use willow_client::{ClientEvent, VoiceSignalPayload}; + +use crate::app::{ + handle_voice_answer, handle_voice_create_offer, handle_voice_offer, VoiceManagerHandle, + WebClientHandle, +}; +use crate::state::{AppState, AppWriteSignals}; + +/// Process a batch of [`ClientEvent`]s and update signals. +/// +/// Reads current values from `state` (read signals) and writes to `write` +/// (write signals). Batches refreshes to avoid redundant signal updates. +pub fn process_event_batch( + events: &[ClientEvent], + handle: &WebClientHandle, + state: &AppState, + write: &AppWriteSignals, + voice_manager: &VoiceManagerHandle, +) { + use leptos::prelude::*; + + let mut needs_msg_refresh = false; + let mut needs_peer_refresh = false; + let mut needs_channel_refresh = false; + + for event in events { + match event { + ClientEvent::MessageReceived { .. } => { + needs_msg_refresh = true; + } + ClientEvent::MessageEdited { .. } + | ClientEvent::MessageDeleted { .. } + | ClientEvent::ReactionAdded { .. } + | ClientEvent::SyncCompleted { .. } => { + needs_msg_refresh = true; + } + ClientEvent::PeerConnected(_) => { + needs_peer_refresh = true; + write + .network + .set_connection_status + .set("connected".to_string()); + write.network.set_loading.set(false); + } + ClientEvent::PeerDisconnected(_) => { + needs_peer_refresh = true; + } + ClientEvent::Listening(_) => { + let status = state.network.connection_status.get_untracked(); + if status == "connecting" { + write + .network + .set_connection_status + .set("connecting".to_string()); + } + } + ClientEvent::ChannelCreated(_) | ClientEvent::ChannelDeleted(_) => { + needs_channel_refresh = true; + } + ClientEvent::ProfileUpdated { .. } => { + let h = handle.clone(); + write.server.set_display_name.set(h.display_name()); + needs_peer_refresh = true; + } + ClientEvent::VoiceJoined { + channel_id, + peer_id, + } => { + let ch = channel_id.clone(); + let pid = peer_id.clone(); + write.voice.set_voice_participants_map.update(|m| { + let participants = m.entry(ch.clone()).or_default(); + if !participants.contains(&pid) { + participants.push(pid.clone()); + } + }); + // If we're in this channel, create offer to new peer. + if state.voice.voice_channel.get_untracked() == Some(ch) { + let vm = voice_manager.clone(); + let p = pid; + wasm_bindgen_futures::spawn_local(handle_voice_create_offer(vm, p)); + } + } + ClientEvent::VoiceLeft { + channel_id, + peer_id, + } => { + let ch = channel_id.clone(); + let pid = peer_id.clone(); + write.voice.set_voice_participants_map.update(|m| { + if let Some(v) = m.get_mut(&ch) { + v.retain(|p| p != &pid); + } + }); + voice_manager.borrow_mut().close_connection(peer_id); + } + ClientEvent::VoiceSignal { + from_peer, signal, .. + } => { + let vm = voice_manager.clone(); + let from = from_peer.clone(); + match signal { + VoiceSignalPayload::Offer(sdp) => { + let s = sdp.clone(); + wasm_bindgen_futures::spawn_local(handle_voice_offer(vm, from, s)); + } + VoiceSignalPayload::Answer(sdp) => { + let s = sdp.clone(); + wasm_bindgen_futures::spawn_local(handle_voice_answer(vm, from, s)); + } + VoiceSignalPayload::IceCandidate(json) => { + let _ = vm.borrow().handle_ice_candidate(&from, json); + } + } + } + _ => {} + } + } + + if needs_msg_refresh { + let ch = state.chat.current_channel.get_untracked(); + let h = handle.clone(); + let new_msgs = h.messages(&ch); + // Only update if messages actually changed (avoids destroying + // open action sheets by re-rendering the message list). + let old_msgs = state.chat.messages.get_untracked(); + let changed = new_msgs.len() != old_msgs.len() + || new_msgs.last().map(|m| &m.id) != old_msgs.last().map(|m| &m.id) + || new_msgs.iter().zip(old_msgs.iter()).any(|(a, b)| { + a.id != b.id + || a.body != b.body + || a.edited != b.edited + || a.deleted != b.deleted + || a.reactions.len() != b.reactions.len() + }); + if changed { + write.chat.set_messages.set(new_msgs); + } + // Refresh pinned messages and labels. + write.chat.set_pinned_messages.set(h.pinned_messages(&ch)); + let mut labels = HashMap::new(); + for msg in h.messages(&ch) { + let label = if h.is_pinned(&ch, &msg.id) { + "Unpin" + } else { + "Pin" + }; + labels.insert(msg.id.clone(), label.to_string()); + } + write.chat.set_pin_labels.set(labels); + // Update unread counts from the active server. + write.server.set_unread.set(h.unread_counts()); + } + if needs_peer_refresh { + let h = handle.clone(); + let peer_list = h.server_members(); + let count = peer_list.iter().filter(|(_, _, online)| *online).count(); + write.network.set_peers.set(peer_list); + write.network.set_peer_count.set(count); + if count > 0 { + write + .network + .set_connection_status + .set("connected".to_string()); + } else { + write + .network + .set_connection_status + .set("connecting".to_string()); + } + } + if needs_channel_refresh { + let h = handle.clone(); + write.chat.set_channels.set(h.channels()); + write.server.set_roles.set(extract_roles(&h)); + } + if needs_msg_refresh || needs_peer_refresh { + // Roles may change via sync events, so refresh on any state change. + write.server.set_roles.set(extract_roles(handle)); + } +} + +/// Full refresh of all signals from the client. Used after server +/// creation, joining, and on initial load. +pub fn refresh_all_signals(handle: &WebClientHandle, write: &AppWriteSignals) { + use leptos::prelude::*; + + write.server.set_servers.set(handle.server_list()); + write.chat.set_channels.set(handle.channels()); + write.network.set_peer_id.set(handle.peer_id()); + write.server.set_display_name.set(handle.display_name()); + write.server.set_roles.set(extract_roles(handle)); + if let Some(id) = handle.active_server_id() { + write.server.set_active_server_id.set(id.to_string()); + } + write + .server + .set_active_server_name + .set(handle.active_server_name()); + let ch = handle.current_channel(); + write.chat.set_current_channel.set(ch.clone()); + write.chat.set_messages.set(handle.messages(&ch)); + write.ui.set_show_settings.set(false); + write.ui.set_show_server_settings.set(false); + write.ui.set_show_add_server.set(false); +} + +/// Extract roles from the client's event-sourced state as a list of +/// `(role_id, role_name, permission_strings)` tuples for reactive signals. +pub fn extract_roles(handle: &WebClientHandle) -> Vec<(String, String, Vec)> { + handle.roles_data() +} diff --git a/crates/web/src/handlers.rs b/crates/web/src/handlers.rs new file mode 100644 index 00000000..ed2b2875 --- /dev/null +++ b/crates/web/src/handlers.rs @@ -0,0 +1,159 @@ +//! Handler constructors for UI actions. +//! +//! Each function takes a `WebClientHandle`, `AppState`, and `AppWriteSignals` +//! and returns a closure suitable for use as a Leptos event handler. + +use std::collections::HashMap; + +use leptos::prelude::*; + +use crate::app::WebClientHandle; +use crate::state::{AppState, AppWriteSignals}; + +/// Create a handler for sending messages (including replies). +pub fn make_send_handler( + handle: WebClientHandle, + state: AppState, + write: AppWriteSignals, +) -> impl Fn(String) + Clone + 'static { + move |body: String| { + let ch = state.chat.current_channel.get_untracked(); + if let Some(reply_msg) = state.chat.replying_to.get_untracked() { + let _ = handle.send_reply(&ch, &reply_msg.id, &body); + write.chat.set_replying_to.set(None); + } else { + let _ = handle.send_message(&ch, &body); + } + write.chat.set_messages.set(handle.messages(&ch)); + } +} + +/// Create a handler for editing messages. +pub fn make_edit_handler( + handle: WebClientHandle, + state: AppState, + write: AppWriteSignals, +) -> impl Fn((String, String)) + Clone + 'static { + move |(message_id, new_body): (String, String)| { + let ch = state.chat.current_channel.get_untracked(); + let _ = handle.edit_message(&ch, &message_id, &new_body); + write.chat.set_editing.set(None); + write.chat.set_messages.set(handle.messages(&ch)); + } +} + +/// Create a handler for deleting messages. +pub fn make_delete_handler( + handle: WebClientHandle, + state: AppState, + write: AppWriteSignals, +) -> impl Fn(willow_client::DisplayMessage) + Clone + 'static { + move |msg: willow_client::DisplayMessage| { + let ch = state.chat.current_channel.get_untracked(); + let _ = handle.delete_message(&ch, &msg.id); + write.chat.set_messages.set(handle.messages(&ch)); + } +} + +/// Create a handler for adding/toggling reactions. +pub fn make_react_handler( + handle: WebClientHandle, + state: AppState, + write: AppWriteSignals, +) -> impl Fn((willow_client::DisplayMessage, String)) + Clone + 'static { + move |(msg, emoji): (willow_client::DisplayMessage, String)| { + let ch = state.chat.current_channel.get_untracked(); + let _ = handle.react(&ch, &msg.id, &emoji); + write.chat.set_messages.set(handle.messages(&ch)); + } +} + +/// Create a handler for switching channels. +pub fn make_channel_click_handler( + handle: WebClientHandle, + state: AppState, + write: AppWriteSignals, +) -> impl Fn(String) + Clone + 'static { + let _ = state; // state used via write signals only + move |name: String| { + write.chat.set_current_channel.set(name.clone()); + write.ui.set_show_sidebar.set(false); + write.ui.set_show_pinned.set(false); + write.chat.set_messages.set(handle.messages(&name)); + write + .chat + .set_pinned_messages + .set(handle.pinned_messages(&name)); + let mut labels = HashMap::new(); + for msg in handle.messages(&name) { + let label = if handle.is_pinned(&name, &msg.id) { + "Unpin" + } else { + "Pin" + }; + labels.insert(msg.id.clone(), label.to_string()); + } + write.chat.set_pin_labels.set(labels); + handle.switch_channel(&name); + // Clear unread for this channel. + write.server.set_unread.update(|m| { + m.remove(&name); + }); + } +} + +/// Create a handler for switching servers. +pub fn make_server_click_handler( + handle: WebClientHandle, + write: AppWriteSignals, +) -> impl Fn(String) + Clone + 'static { + move |id: String| { + handle.switch_server(&id); + write.server.set_active_server_id.set(id); + write.server.set_servers.set(handle.server_list()); + let chs = handle.channels(); + write.chat.set_channels.set(chs.clone()); + let first_ch = chs + .first() + .cloned() + .unwrap_or_else(|| "general".to_string()); + write.chat.set_current_channel.set(first_ch.clone()); + write.chat.set_messages.set(handle.messages(&first_ch)); + write + .server + .set_active_server_name + .set(handle.active_server_name()); + write.ui.set_show_settings.set(false); + write.ui.set_show_add_server.set(false); + } +} + +/// Create a handler for pinning/unpinning messages. +pub fn make_pin_handler( + handle: WebClientHandle, + state: AppState, + write: AppWriteSignals, +) -> impl Fn(willow_client::DisplayMessage) + Clone + 'static { + move |msg: willow_client::DisplayMessage| { + let ch = state.chat.current_channel.get_untracked(); + if handle.is_pinned(&ch, &msg.id) { + let _ = handle.unpin_message(&ch, &msg.id); + } else { + let _ = handle.pin_message(&ch, &msg.id); + } + write + .chat + .set_pinned_messages + .set(handle.pinned_messages(&ch)); + let mut labels = HashMap::new(); + for m in handle.messages(&ch) { + let label = if handle.is_pinned(&ch, &m.id) { + "Unpin" + } else { + "Pin" + }; + labels.insert(m.id.clone(), label.to_string()); + } + write.chat.set_pin_labels.set(labels); + } +} diff --git a/crates/web/src/main.rs b/crates/web/src/main.rs index 3aff16de..5762bbb1 100644 --- a/crates/web/src/main.rs +++ b/crates/web/src/main.rs @@ -1,5 +1,7 @@ mod app; mod components; +mod event_processing; +mod handlers; mod state; pub(crate) mod util; pub mod voice; diff --git a/crates/web/src/state.rs b/crates/web/src/state.rs index aeed54ea..3e44ee92 100644 --- a/crates/web/src/state.rs +++ b/crates/web/src/state.rs @@ -66,6 +66,8 @@ pub struct VoiceState { pub voice_channel: ReadSignal>, pub voice_muted: ReadSignal, pub voice_deafened: ReadSignal, + /// Participants per voice channel. Not yet rendered but tracked for future use. + #[allow(dead_code)] pub voice_participants_map: ReadSignal>>, pub voice_channel_name: ReadSignal, } From b5ad80598566e9676b9f762609afe7a01d0c144a Mon Sep 17 00:00:00 2001 From: Noah Date: Tue, 24 Mar 2026 23:35:11 -0700 Subject: [PATCH 6/6] chore: add design spec, implementation plan, and format fixes Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/client/src/lib.rs | 289 +-- .../2026-03-24-async-client-ui-refactor.md | 1681 +++++++++++++++++ ...6-03-24-async-client-ui-refactor-design.md | 353 ++++ 3 files changed, 2117 insertions(+), 206 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-24-async-client-ui-refactor.md create mode 100644 docs/superpowers/specs/2026-03-24-async-client-ui-refactor-design.md diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 6d88e5f3..d2d804fb 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -113,11 +113,7 @@ pub struct ClientEventLoop { /// Helper: apply an event to the event-sourced state and store it. /// Callable with explicit parameters to avoid borrow conflicts. /// `persistence` is passed separately to avoid double-borrowing SharedState. -fn apply_event_shared( - state: &mut ClientState, - persistence: bool, - event: &willow_state::Event, -) { +fn apply_event_shared(state: &mut ClientState, persistence: bool, event: &willow_state::Event) { willow_state::apply_lenient(&mut state.event_state, event); state.event_store.append(event.clone()); let hash = state.event_state.hash(); @@ -553,9 +549,7 @@ impl ClientHandle { } let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::SendVoiceLeave { - channel_id: ch, - }); + .unbounded_send(network::NetworkCommand::SendVoiceLeave { channel_id: ch }); } } @@ -585,10 +579,7 @@ impl ClientHandle { /// Returns the voice channel we are currently in, if any. pub fn active_voice_channel(&self) -> Option { - self.shared - .borrow() - .active_voice_channel - .clone() + self.shared.borrow().active_voice_channel.clone() } /// Returns whether we are currently muted. @@ -637,7 +628,7 @@ impl ClientHandle { let mut shared = self.shared.borrow_mut(); if shared.state.servers.contains_key(server_id) { shared.state.active_server = Some(server_id.to_string()); - init_event_state_on_shared(&mut shared,server_id); + init_event_state_on_shared(&mut shared, server_id); reconcile_topic_map(&mut shared.state); } } @@ -676,8 +667,7 @@ impl ClientHandle { /// Returns the server ID. pub fn create_server(&self, name: &str) -> anyhow::Result { let mut shared = self.shared.borrow_mut(); - let mut server = - willow_channel::Server::new(name, shared.identity.peer_id()); + let mut server = willow_channel::Server::new(name, shared.identity.peer_id()); let server_id = server.id.to_string(); // Create default "general" channel. @@ -708,11 +698,8 @@ impl ClientHandle { // Initialize event-sourced state for this server. let peer_id = shared.identity.peer_id().to_string(); - shared.state.event_state = willow_state::ServerState::new( - server_id.clone(), - name.to_string(), - peer_id.clone(), - ); + shared.state.event_state = + willow_state::ServerState::new(server_id.clone(), name.to_string(), peer_id.clone()); // Open event store. if shared.config.persistence { @@ -775,18 +762,11 @@ impl ClientHandle { let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); // Also update the global profile for backward compat. let pid = shared.identity.peer_id().to_string(); - shared - .state - .profiles - .names - .insert(pid, name.to_string()); + shared.state.profiles.names.insert(pid, name.to_string()); storage::save_profile(&storage::LocalProfile { display_name: name.to_string(), @@ -831,12 +811,7 @@ impl ClientHandle { } /// Send a reply to a specific message. - pub fn send_reply( - &self, - channel: &str, - parent_id: &str, - body: &str, - ) -> anyhow::Result<()> { + pub fn send_reply(&self, channel: &str, parent_id: &str, body: &str) -> anyhow::Result<()> { let parent = willow_messaging::MessageId(uuid::Uuid::parse_str(parent_id).unwrap_or_default()); let content = Content::Reply { @@ -869,13 +844,7 @@ impl ClientHandle { }); drop(shared); - self.send_content( - channel, - content, - body, - preview, - Some(parent_id.to_string()), - ) + self.send_content(channel, content, body, preview, Some(parent_id.to_string())) } /// Share a small file inline by base64-encoding it into a text message. @@ -925,10 +894,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } @@ -954,21 +920,13 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } /// Add a reaction to a message. - pub fn react( - &self, - _channel: &str, - message_id: &str, - emoji: &str, - ) -> anyhow::Result<()> { + pub fn react(&self, _channel: &str, message_id: &str, emoji: &str) -> anyhow::Result<()> { let mut shared = self.shared.borrow_mut(); let _ = shared .state @@ -987,12 +945,12 @@ impl ClientHandle { }, }; apply_event_on_shared(&mut shared, &reaction_event); - let _ = self.cmd_tx.unbounded_send( - network::NetworkCommand::BroadcastEvent { + let _ = self + .cmd_tx + .unbounded_send(network::NetworkCommand::BroadcastEvent { event: reaction_event, topic: None, - }, - ); + }); Ok(()) } @@ -1018,10 +976,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } @@ -1047,10 +1002,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } @@ -1155,10 +1107,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); shared.state.chat.current_channel = name.to_string(); @@ -1206,10 +1155,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } @@ -1245,8 +1191,7 @@ impl ClientHandle { .active() .map(|ctx| ctx.channel_names()) .unwrap_or_default(); - shared.state.chat.current_channel = - names.first().cloned().unwrap_or_default(); + shared.state.chat.current_channel = names.first().cloned().unwrap_or_default(); } // Create and apply event, then broadcast it. @@ -1263,10 +1208,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } @@ -1291,10 +1233,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); } /// Revoke trust from a peer. @@ -1317,10 +1256,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); } /// Kick a member, rotating channel keys. @@ -1371,10 +1307,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } @@ -1406,10 +1339,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } @@ -1439,10 +1369,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } @@ -1480,10 +1407,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } @@ -1526,10 +1450,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } @@ -1549,10 +1470,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } @@ -1586,10 +1504,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } @@ -1609,10 +1524,7 @@ impl ClientHandle { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); Ok(()) } @@ -1658,10 +1570,8 @@ impl ClientHandle { } else { // Create a new server context for this server. // Use the ORIGINAL server ID from the invite so topics match. - let mut server = willow_channel::Server::new( - &accepted.server_name, - shared.identity.peer_id(), - ); + let mut server = + willow_channel::Server::new(&accepted.server_name, shared.identity.peer_id()); server.id = willow_channel::ServerId( uuid::Uuid::parse_str(&server_id).unwrap_or_else(|_| uuid::Uuid::new_v4()), ); @@ -1696,14 +1606,19 @@ impl ClientHandle { } shared.state.active_server = Some(server_id.clone()); - init_event_state_on_shared(&mut shared,&server_id); + init_event_state_on_shared(&mut shared, &server_id); // Fix the event_state owner to be the ACTUAL server owner from the invite, // not the joining peer. This is critical for permission checks -- without it, // the actual owner's events (CreateChannel, etc.) get rejected. shared.state.event_state.owner = accepted.owner.clone(); // Also add the owner as a member so permissions work. - if !shared.state.event_state.members.contains_key(&accepted.owner) { + if !shared + .state + .event_state + .members + .contains_key(&accepted.owner) + { shared.state.event_state.members.insert( accepted.owner.clone(), willow_state::Member { @@ -1732,12 +1647,12 @@ impl ClientHandle { }); if let Some(ctx) = shared.state.servers.get(&server_id) { for topic in ctx.topic_map.keys() { - let _ = self.cmd_tx.unbounded_send( - network::NetworkCommand::RequestSync { + let _ = self + .cmd_tx + .unbounded_send(network::NetworkCommand::RequestSync { state_hash: willow_state::StateHash::ZERO, topic: Some(topic.clone()), - }, - ); + }); } } @@ -1853,8 +1768,7 @@ impl ClientHandle { let shared = self.shared.borrow(); // Collect ALL channel_ids that map to this channel name. // This handles the ID mismatch between owner and joiner. - let mut channel_ids: std::collections::HashSet = - std::collections::HashSet::new(); + let mut channel_ids: std::collections::HashSet = std::collections::HashSet::new(); // From topic_map (legacy). if let Some(ctx) = shared.state.active() { @@ -1907,9 +1821,7 @@ impl ClientHandle { .profiles .get(&pm.author) .map(|p| p.display_name.clone()) - .unwrap_or_else(|| { - shared.state.profiles.display_name(&pm.author) - }); + .unwrap_or_else(|| shared.state.profiles.display_name(&pm.author)); let text = if pm.body.len() > 50 { format!("{}...", &pm.body[..50]) } else { @@ -1933,9 +1845,7 @@ impl ClientHandle { .profiles .get(pid) .map(|p| p.display_name.clone()) - .unwrap_or_else(|| { - shared.state.profiles.display_name(pid) - }) + .unwrap_or_else(|| shared.state.profiles.display_name(pid)) }) .collect(); (emoji.clone(), names) @@ -2089,12 +1999,7 @@ impl ClientHandle { /// Returns the current channel name from the chat state. pub fn current_channel(&self) -> String { - self.shared - .borrow() - .state - .chat - .current_channel - .clone() + self.shared.borrow().state.chat.current_channel.clone() } /// Returns unread counts keyed by channel name for the active server. @@ -2239,8 +2144,7 @@ fn emit_client_events_for( let topic = util::make_topic(&ctx.server, name); if !ctx.topic_map.contains_key(&topic) { let cid = willow_channel::ChannelId( - uuid::Uuid::parse_str(channel_id) - .unwrap_or_else(|_| uuid::Uuid::new_v4()), + uuid::Uuid::parse_str(channel_id).unwrap_or_else(|_| uuid::Uuid::new_v4()), ); ctx.topic_map.insert(topic.clone(), (name.clone(), cid)); // The caller (process_batch) will handle subscribing via cmd_tx if needed. @@ -2420,10 +2324,7 @@ impl ClientEventLoop { /// Process a batch of network events, returning the resulting client events /// and a flag indicating whether state verification should be triggered. - fn process_batch( - &self, - net_events: Vec, - ) -> (Vec, bool) { + fn process_batch(&self, net_events: Vec) -> (Vec, bool) { let mut events = Vec::new(); let mut needs_verify = false; @@ -2446,8 +2347,7 @@ impl ClientEventLoop { let mut shared = self.shared.borrow_mut(); // Apply to event-sourced state. - let result = - willow_state::apply_lenient(&mut shared.state.event_state, &event); + let result = willow_state::apply_lenient(&mut shared.state.event_state, &event); if matches!(result, willow_state::ApplyResult::Applied) { shared.state.event_store.append(event.clone()); let hash = shared.state.event_state.hash(); @@ -2477,9 +2377,11 @@ impl ClientEventLoop { if !missing.is_empty() { let count = missing.len(); tracing::info!(count, "sending event sync batch"); - let _ = self.cmd_tx.unbounded_send( - network::NetworkCommand::SendSyncBatch { events: missing }, - ); + let _ = + self.cmd_tx + .unbounded_send(network::NetworkCommand::SendSyncBatch { + events: missing, + }); } } @@ -2603,9 +2505,7 @@ impl ClientEventLoop { network::NetworkEvent::TypingReceived { peer_id, channel } => { let mut shared = self.shared.borrow_mut(); let now = util::current_time_ms(); - shared - .typing_peers - .insert(peer_id.clone(), (channel, now)); + shared.typing_peers.insert(peer_id.clone(), (channel, now)); // Also track as online peer. if !shared.state.chat.peers.contains(&peer_id) { shared.state.chat.peers.push(peer_id.clone()); @@ -2695,10 +2595,7 @@ impl ClientEventLoop { apply_event_on_shared(&mut shared, &event); let _ = self .cmd_tx - .unbounded_send(network::NetworkCommand::BroadcastEvent { - event, - topic: None, - }); + .unbounded_send(network::NetworkCommand::BroadcastEvent { event, topic: None }); } } @@ -2755,7 +2652,10 @@ fn parse_permission(s: &str) -> anyhow::Result { /// Create a test-only ClientHandle without connecting to the network. /// The returned client has mpsc channels wired up but no background task. #[cfg(test)] -pub(crate) fn test_client() -> (ClientHandle, futures_mpsc::UnboundedReceiver) { +pub(crate) fn test_client() -> ( + ClientHandle, + futures_mpsc::UnboundedReceiver, +) { let identity = Identity::generate(); let (cmd_tx, cmd_rx) = futures_mpsc::unbounded(); let (_event_tx, event_rx) = futures_mpsc::unbounded::(); @@ -2971,10 +2871,7 @@ mod tests { client.create_channel("other").unwrap(); client.switch_channel("general"); - assert_eq!( - client.shared.borrow().state.chat.current_channel, - "general" - ); + assert_eq!(client.shared.borrow().state.chat.current_channel, "general"); } #[test] @@ -3055,10 +2952,8 @@ mod tests { let (client, _rx) = test_client(); // Create a second server context. - let server2 = willow_channel::Server::new( - "Second Server", - client.shared.borrow().identity.peer_id(), - ); + let server2 = + willow_channel::Server::new("Second Server", client.shared.borrow().identity.peer_id()); let server2_id = server2.id.to_string(); let ctx2 = ServerContext { server: server2, @@ -3073,13 +2968,7 @@ mod tests { .servers .insert(server2_id.clone(), ctx2); - let original_id = client - .shared - .borrow() - .state - .active_server - .clone() - .unwrap(); + let original_id = client.shared.borrow().state.active_server.clone().unwrap(); assert_ne!(original_id, server2_id); client.switch_server(&server2_id); @@ -3287,13 +3176,7 @@ mod tests { // Send a message on server 1. client.send_message("general", "server1 msg").unwrap(); - let server1_id = client - .shared - .borrow() - .state - .active_server - .clone() - .unwrap(); + let server1_id = client.shared.borrow().state.active_server.clone().unwrap(); // When viewing server 1, see server 1 messages. let msgs = client.messages("general"); @@ -3324,10 +3207,8 @@ mod tests { assert_eq!(list[0].1, "Test Server"); // Add a second server. - let server2 = willow_channel::Server::new( - "Second", - client.shared.borrow().identity.peer_id(), - ); + let server2 = + willow_channel::Server::new("Second", client.shared.borrow().identity.peer_id()); let server2_id = server2.id.to_string(); client.shared.borrow_mut().state.servers.insert( server2_id, @@ -3401,12 +3282,8 @@ mod tests { // Send 2 messages to each channel. for name in &ch_names { - client - .send_message(name, &format!("{name} msg 1")) - .unwrap(); - client - .send_message(name, &format!("{name} msg 2")) - .unwrap(); + client.send_message(name, &format!("{name} msg 1")).unwrap(); + client.send_message(name, &format!("{name} msg 2")).unwrap(); } // Verify messages() returns correct messages per channel. @@ -3535,10 +3412,7 @@ mod tests { display_name: "EventAlice".into(), }, }; - willow_state::apply_lenient( - &mut client.shared.borrow_mut().state.event_state, - &event, - ); + willow_state::apply_lenient(&mut client.shared.borrow_mut().state.event_state, &event); // Verify display_name() reads from event_state.profiles. assert_eq!( @@ -3795,10 +3669,7 @@ mod tests { } client.create_server("Test").unwrap(); - assert_eq!( - client.shared.borrow().state.chat.current_channel, - "general" - ); + assert_eq!(client.shared.borrow().state.chat.current_channel, "general"); } #[test] @@ -3958,7 +3829,13 @@ mod tests { #[test] fn seen_message_ids_prevents_duplicates() { let (client, _rx) = test_client(); - assert!(client.shared.borrow().state.chat.seen_message_ids.is_empty()); + assert!(client + .shared + .borrow() + .state + .chat + .seen_message_ids + .is_empty()); client.send_message("general", "test").unwrap(); let _ = client.shared.borrow().state.chat.seen_message_ids.len(); diff --git a/docs/superpowers/plans/2026-03-24-async-client-ui-refactor.md b/docs/superpowers/plans/2026-03-24-async-client-ui-refactor.md new file mode 100644 index 00000000..2995bf9b --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-async-client-ui-refactor.md @@ -0,0 +1,1681 @@ +# Async Client + UI Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate polling from the Willow web UI by replacing `std::sync::mpsc` with async channels, splitting the monolithic `Client` into `ClientHandle` + `ClientEventLoop`, and restructuring the Leptos UI with context-based state management. + +**Architecture:** The `willow-client` crate's `Client` struct splits into `SharedState` (wrapped in `Rc>`), `ClientHandle` (cloneable command interface), and `ClientEventLoop` (async event processor). The `willow-web` crate replaces its 50ms poll loop with `spawn_local` tasks that await events, and replaces 30 prop-drilled signals with a context-provided `AppState` struct. + +**Tech Stack:** Rust, Leptos 0.7 (CSR), futures 0.3, wasm-bindgen-futures, gloo-timers, futures::channel::mpsc + +**Spec:** `docs/superpowers/specs/2026-03-24-async-client-ui-refactor-design.md` + +--- + +## File Map + +### Client crate (`crates/client/src/`) + +| File | Action | Responsibility | +|------|--------|----------------| +| `lib.rs` (3630 lines) | **Major rewrite** | Remove `Client` struct. Add `SharedState`, `ClientHandle`, `ClientEventLoop`, constructor `new()`, and `test_handle()` helper. Move 48 pub methods from `Client` to `ClientHandle`. Move `poll()` logic to `ClientEventLoop::run()`. | +| `events.rs` (108 lines) | **Modify** | Remove `ClientNotification` enum. | +| `network.rs` (592 lines) | **Modify** | Change `spawn_network()` signatures from `std::sync::mpsc` to `futures::channel::mpsc`. Remove 16ms tick timer in WASM `run_network_wasm()`. Cfg-gate native `run_network()` and `spawn_network()` out (native is out of scope and allowed to break). | +| `state.rs` (270 lines) | **No change** | `ClientState`, `ChatState`, `ServerContext` stay as-is. | + +### Client crate config + +| File | Action | Responsibility | +|------|--------|----------------| +| `Cargo.toml` | **Modify** | Add `futures` as a dependency (needed for `futures::channel::mpsc`). | + +### Web crate (`crates/web/src/`) + +| File | Action | Responsibility | +|------|--------|----------------| +| `app.rs` (802 lines) | **Major rewrite** | Slim to ~50 lines: create handle + event loop, build `AppState`/`AppWriteSignals`, `provide_context`, spawn async tasks, render layout shell. | +| `state.rs` | **Create** | `AppState`, `AppWriteSignals`, all sub-structs (`ChatState`, `NetworkState`, `ServerState`, `UiState`, `VoiceState`, `ChannelViewState`, and write counterparts). Constructor `fn create_signals() -> (AppState, AppWriteSignals)`. | +| `event_processing.rs` | **Create** | `process_event_batch()`, `refresh_all_signals()`, `extract_roles()`. | +| `handlers.rs` | **Create** | `make_send_handler()`, `make_edit_handler()`, `make_delete_handler()`, `make_react_handler()`, `make_channel_click_handler()`, `make_server_click_handler()`, `make_pin_handler()`. | +| `voice.rs` (320 lines) | **Minor modify** | Add `handle_voice_event()` helper called from `process_event_batch`. | +| `components/sidebar.rs` (308 lines) | **Modify** | Replace `ClientHandle` prop with `use_context`. | +| `components/member_list.rs` (107 lines) | **Modify** | Replace `ClientHandle` prop with `use_context`. | +| `components/welcome.rs` (56 lines) | **Modify** | Replace `ClientHandle` prop with `use_context`. | +| `components/add_server.rs` (168 lines) | **Modify** | Replace `ClientHandle` prop with `use_context`. | +| `components/server_settings.rs` (131 lines) | **Modify** | Replace `ClientHandle` prop with `use_context`. | +| `components/settings.rs` (92 lines) | **Modify** | Replace `ClientHandle` prop with `use_context`. | +| `components/file_share.rs` (154 lines) | **Modify** | Replace `ClientHandle` prop with `use_context`. | +| `components/roles.rs` | **Modify** | Replace `ClientHandle` prop with `use_context`. | +| `components/chat.rs` | **Modify** | Replace signal props with `use_context`. | +| `components/input.rs` | **Modify** | Minor: callbacks stay as props, no ClientHandle. | +| `components/message.rs` | **Modify** | Minor: callbacks stay as props, no ClientHandle. | +| `components/mod.rs` (29 lines) | **Modify** | Add exports for new modules. | + +### Web crate config + +| File | Action | Responsibility | +|------|--------|----------------| +| `Cargo.toml` | **Modify** | Add `futures` dep. Move `gloo-timers` from dev-deps to deps. | + +--- + +## Task Ordering + +The plan is structured so each task produces a compiling, testable state: + +1. **Tasks 1-3:** Client crate refactor (async channels, SharedState + ClientHandle, EventLoop) +2. **Task 4:** Fix client tests +3. **Task 5:** Web crate dependencies + state module +4. **Task 6:** Event processing + handlers modules +5. **Task 7:** Rewrite App component +6. **Task 8:** Migrate components to context +7. **Task 9:** Verify everything compiles and tests pass + +--- + +### Task 1: Replace `std::sync::mpsc` with `futures::channel::mpsc` in client crate + +**Files:** +- Modify: `crates/client/Cargo.toml` +- Modify: `crates/client/src/lib.rs` (channel creation in `new()` and `test_client()`) +- Modify: `crates/client/src/network.rs` (function signatures, remove WASM tick timer) + +This task changes the channel types throughout the client crate while keeping the `Client` struct intact. The `poll()` method switches from `try_recv()` to a loop that drains via the futures `try_next()` method. + +- [ ] **Step 1: Add `futures` dependency to client Cargo.toml** + +In `crates/client/Cargo.toml`, add `futures` to the `[dependencies]` section: + +```toml +futures = "0.3" +``` + +- [ ] **Step 2: Update `DeferredPair` type and channel creation in `lib.rs`** + +In `crates/client/src/lib.rs`: + +Replace the `std::sync::mpsc as std_mpsc` import and `DeferredPair` type: + +```rust +// Before: +use std::sync::mpsc as std_mpsc; + +type DeferredPair = ( + std_mpsc::Sender, + std_mpsc::Receiver, +); + +// After: +use futures::channel::mpsc as futures_mpsc; + +type DeferredPair = ( + futures_mpsc::UnboundedSender, + futures_mpsc::UnboundedReceiver, +); +``` + +Update `Client` struct fields: + +```rust +// Before: +pub(crate) cmd_tx: std_mpsc::Sender, +pub(crate) event_rx: std_mpsc::Receiver, + +// After: +pub(crate) cmd_tx: futures_mpsc::UnboundedSender, +pub(crate) event_rx: futures_mpsc::UnboundedReceiver, +``` + +Update `Client::new()` channel creation: + +```rust +// Before: +let (cmd_tx, cmd_rx) = std_mpsc::channel(); +let (event_tx, event_rx) = std_mpsc::channel(); + +// After: +let (cmd_tx, cmd_rx) = futures_mpsc::unbounded(); +let (event_tx, event_rx) = futures_mpsc::unbounded(); +``` + +Update `Client::connect()` to pass the new types to `spawn_network`. + +Update `cmd_tx.send(...)` calls throughout `lib.rs` to `cmd_tx.unbounded_send(...)`. There are many call sites — use find-and-replace: `self.cmd_tx.send(` → `self.cmd_tx.unbounded_send(`. + +Update `poll()` to use `futures::stream::StreamExt::try_next`: + +```rust +// Before: +while let Ok(net_event) = self.event_rx.try_recv() { + +// After: +use futures::StreamExt; +while let Ok(Some(net_event)) = self.event_rx.try_next() { +``` + +Remove `notification_tx` field and all `self.notify(...)` calls. Remove the `notify()` method. Remove `with_notifications()` method. + +- [ ] **Step 3: Update `spawn_network()` signatures in `network.rs`** + +In `crates/client/src/network.rs`: + +Update both `spawn_network` function signatures (native and WASM): + +```rust +// Before: +pub fn spawn_network( + identity: willow_identity::Identity, + event_tx: std::sync::mpsc::Sender, + cmd_rx: std::sync::mpsc::Receiver, + config: willow_network::NetworkConfig, +) + +// After: +pub fn spawn_network( + identity: willow_identity::Identity, + event_tx: futures::channel::mpsc::UnboundedSender, + cmd_rx: futures::channel::mpsc::UnboundedReceiver, + config: willow_network::NetworkConfig, +) +``` + +Update both `run_network` / `run_network_wasm` signatures to match. + +Update `event_tx.send(...)` to `event_tx.unbounded_send(...)` throughout `network.rs`. + +- [ ] **Step 4: Remove 16ms tick timer in WASM network loop** + +In `crates/client/src/network.rs`, in `run_network_wasm()`: + +Remove the tick timer: + +```rust +// DELETE these lines: +let mut tick = Box::pin(futures::stream::unfold((), |_| async { + gloo_timers::future::TimeoutFuture::new(16).await; + Some(((), ())) +})) +.fuse(); +``` + +Add `cmd_rx.next()` as a select arm instead: + +```rust +use futures::StreamExt; + +// In the select! loop: +futures::select! { + event = events.next() => { + // ... existing event handling ... + } + cmd = cmd_rx.next() => { + if let Some(cmd) = cmd { + handle_network_command(&cmd, &node, &mut file_mgr)?; + } + } + complete => break, +} +``` + +Remove the trailing `while let Ok(cmd) = cmd_rx.try_recv()` block after the select since commands are now handled inside the select. + +- [ ] **Step 5: Cfg-gate native network functions** + +The native `run_network()` and `spawn_network()` use `tokio::select!` which does not natively support `futures::channel::mpsc`. Since native is out of scope and allowed to break, cfg-gate the entire native block: + +In `crates/client/src/network.rs`, the native `spawn_network` (line ~167) and `run_network` (line ~182) are already behind `#[cfg(not(target_arch = "wasm32"))]`. Leave them as-is — they will fail to compile on native due to the type change, which is acceptable. If clippy warns about dead code, add `#[allow(dead_code)]` to the native functions. + +Alternatively, if the native functions cause compile errors even when targeting WASM (unlikely since they're cfg-gated), wrap them in `#[cfg(not(target_arch = "wasm32"))]` blocks that still use `std::sync::mpsc` internally and add a TODO comment. + +- [ ] **Step 6: Remove `ClientNotification` from events.rs** + +In `crates/client/src/events.rs`, delete the `ClientNotification` enum and its doc comment (lines 94-108). + +In `crates/client/src/lib.rs`, remove the re-export: + +```rust +// Delete: +pub use events::{ClientEvent, ClientNotification}; +// Replace with: +pub use events::ClientEvent; +``` + +- [ ] **Step 7: Update `test_client()` to use new channel types** + +In `crates/client/src/lib.rs`, update `test_client()`: + +```rust +// Before: +let (cmd_tx, cmd_rx) = std_mpsc::channel(); +let (_event_tx, event_rx) = std_mpsc::channel(); + +// After: +let (cmd_tx, cmd_rx) = futures_mpsc::unbounded(); +let (_event_tx, event_rx) = futures_mpsc::unbounded(); +``` + +Return type changes from `(Client, std::sync::mpsc::Receiver)` to `(Client, futures_mpsc::UnboundedReceiver)`. + +- [ ] **Step 8: Verify compilation** + +Run: `cargo check -p willow-client` + +Expected: Compiles with zero errors. Warnings about unused `cmd_rx` in tests are OK. + +- [ ] **Step 9: Run client tests** + +Run: `cargo test -p willow-client` + +Expected: All 93 tests pass. (Tests don't exercise the async event path — they use `test_client()` which doesn't connect.) + +- [ ] **Step 10: Commit** + +```bash +git add crates/client/ +git commit -m "refactor: replace std::sync::mpsc with futures::channel::mpsc in client crate + +Eliminates the 16ms command polling timer in the WASM network loop. +Commands are now awaited directly via futures::select!. +Removes ClientNotification enum (replaced by event channel later)." +``` + +--- + +### Task 2: Extract `SharedState` and `ClientHandle` from `Client` + +**Files:** +- Modify: `crates/client/src/lib.rs` + +This task introduces `SharedState` and `ClientHandle` alongside the existing `Client`. At the end, `Client` is removed and `ClientHandle` takes over. + +- [ ] **Step 1: Define `SharedState` struct** + +Add at the top of `crates/client/src/lib.rs` (after the imports): + +```rust +use std::cell::RefCell; +use std::rc::Rc; + +/// All mutable state shared between ClientHandle and ClientEventLoop. +pub struct SharedState { + pub state: ClientState, + pub identity: Identity, + pub config: ClientConfig, + pub connected: bool, + pub connected_subscribed: bool, + pub typing_peers: HashMap, + pub voice_participants: HashMap>, + pub active_voice_channel: Option, + pub voice_muted: bool, + pub voice_deafened: bool, + pub state_verification_results: HashMap, + pub last_typing_sent_ms: u64, +} +``` + +- [ ] **Step 2: Define `ClientHandle` struct** + +```rust +/// Cloneable command interface for UI components. +/// +/// Wraps shared state and a network command sender. All mutation methods +/// update local state immediately (optimistic) and send commands to the +/// network. All read accessors return data from the shared state. +#[derive(Clone)] +pub struct ClientHandle { + pub(crate) shared: Rc>, + pub(crate) cmd_tx: futures_mpsc::UnboundedSender, + /// Holds deferred channel halves until connect() consumes them. + pub(crate) deferred_channels: Option>>>, +} +``` + +- [ ] **Step 3: Move read-only accessors from `Client` to `ClientHandle`** + +Move these methods to `impl ClientHandle`, adapting `self.field` to `self.shared.borrow().field`: + +- **Remove `state()` and `state_mut()`** — these returned `&ClientState` / `&mut ClientState` which cannot work through `RefCell`. Instead, callers that accessed `client.state().event_state` or `client.state().chat` should use specific accessors. Add any missing targeted accessors as needed (e.g., `event_state_roles()`, `active_server_context()`). Most downstream code already uses the specific accessors like `messages()`, `channels()`, `server_members()`. +- For `extract_roles()` in the web crate, which accesses `client.state().event_state.roles`, add a dedicated `pub fn roles_data(&self) -> Vec<(String, String, Vec)>` accessor on `ClientHandle` that does the borrow internally. +- `peer_id()` → `self.shared.borrow().identity.peer_id().to_string()` +- `display_name()`, `peer_display_name()`, `server_display_name()` +- `messages()`, `channels()`, `channel_kinds()`, `peers()`, `server_members()` +- `is_connected()`, `has_servers()`, `server_list()`, `active_server_name()`, `active_server_id()` +- `pinned_message_ids()`, `pinned_messages()`, `is_pinned()` +- `voice_participants()`, `active_voice_channel()`, `is_voice_muted()`, `is_voice_deafened()` +- `state_hash_agreement()` +- `event_messages()` +- `typing_in()` — note this needs `borrow_mut()` since it prunes stale entries + +For methods that return borrowed data (like `peers()` returning `&[String]`), change to return owned data (e.g., `Vec`). + +- [ ] **Step 4: Move mutation methods from `Client` to `ClientHandle`** + +Move these methods, adapting `self.field` to `self.shared.borrow_mut().field` and `self.cmd_tx.send(...)` to `self.cmd_tx.unbounded_send(...)`: + +- `connect()`, `send_message()`, `send_reply()`, `share_file_inline()` +- `edit_message()`, `delete_message()`, `react()` +- `pin_message()`, `unpin_message()` +- `create_channel()`, `create_voice_channel()`, `delete_channel()`, `switch_channel()` +- `trust_peer()`, `untrust_peer()`, `kick_member()` +- `create_role()`, `delete_role()`, `set_permission()`, `assign_role()` +- `create_server()`, `switch_server()`, `accept_invite()` +- `set_display_name()`, `set_server_display_name()` +- `join_voice()`, `leave_voice()`, `toggle_mute()`, `toggle_deafen()`, `send_voice_signal()` +- `send_typing()`, `verify_state()`, `rename_server()`, `set_server_description()` +- `generate_invite()` + +Also move private helpers: `init_event_state_for_server()`, `reconcile_topic_map()`, `apply_event()`, `broadcast_event()`. + +**Important: `on_connected()` stays on `ClientEventLoop`**, not `ClientHandle`. It is called during event processing (inside `process_batch`) while `SharedState` is already borrowed. If it were on `ClientHandle`, it would try to re-borrow `SharedState`, causing a runtime panic. Instead, `on_connected()` takes `&mut SharedState` and `&UnboundedSender` as parameters, avoiding any re-borrow. It lives on `ClientEventLoop` which owns `cmd_tx`. + +Pattern for each method: + +```rust +// Before (on Client): +pub fn send_message(&mut self, channel: &str, body: &str) -> anyhow::Result<()> { + // uses self.state, self.identity, self.cmd_tx +} + +// After (on ClientHandle): +pub fn send_message(&self, channel: &str, body: &str) -> anyhow::Result<()> { + let mut shared = self.shared.borrow_mut(); + // uses shared.state, shared.identity, self.cmd_tx +} +``` + +Note: methods that previously took `&mut self` can now take `&self` since mutation happens through `RefCell::borrow_mut()`. + +- [ ] **Step 5: Delete the `Client` struct** + +Remove the `Client` struct definition and its `impl` block. All methods now live on `ClientHandle`. + +- [ ] **Step 6: Create the `new()` constructor** + +```rust +/// Create a new client. Returns a handle for UI interaction and an event +/// loop to run in `spawn_local`. +/// +/// Does **not** connect to the network — call [`ClientHandle::connect()`]. +pub fn new(config: ClientConfig) -> (ClientHandle, ClientEventLoop) { + let identity = load_identity(); + + let (cmd_tx, cmd_rx) = futures_mpsc::unbounded(); + let (event_tx, event_rx) = futures_mpsc::unbounded(); + + let deferred = Rc::new(RefCell::new(Some((event_tx, cmd_rx)))); + + let mut state = ClientState::default(); + // ... existing state initialization from Client::new() ... + + let shared = Rc::new(RefCell::new(SharedState { + state, + identity, + config, + connected: false, + connected_subscribed: false, + typing_peers: HashMap::new(), + voice_participants: HashMap::new(), + active_voice_channel: None, + voice_muted: false, + voice_deafened: false, + state_verification_results: HashMap::new(), + last_typing_sent_ms: 0, + })); + + let handle = ClientHandle { + shared: shared.clone(), + cmd_tx, + deferred_channels: Some(deferred), + }; + + let event_loop = ClientEventLoop { + shared, + event_rx, + }; + + (handle, event_loop) +} +``` + +The body of this function is the existing `Client::new()` logic, restructured to populate `SharedState` instead of `Client` fields. + +- [ ] **Step 7: Verify compilation** + +Run: `cargo check -p willow-client` + +Expected: Compiles. Tests will fail at this point (next task fixes them). + +- [ ] **Step 8: Commit** + +```bash +git add crates/client/src/lib.rs +git commit -m "refactor: split Client into SharedState + ClientHandle + +Introduces SharedState (Rc>) and ClientHandle (cloneable). +All 48 public methods moved from Client to ClientHandle. +Client struct removed." +``` + +--- + +### Task 3: Implement `ClientEventLoop` + +**Files:** +- Modify: `crates/client/src/lib.rs` + +- [ ] **Step 1: Define `ClientEventLoop` struct and `run()` method** + +```rust +/// Async event processing loop. Owns the network event receiver. +/// Not cloneable — run exactly one instance via `spawn_local`. +pub struct ClientEventLoop { + pub(crate) shared: Rc>, + pub(crate) event_rx: futures_mpsc::UnboundedReceiver, +} + +impl ClientEventLoop { + /// Run the event processing loop. + /// + /// Awaits network events, applies them to shared state, and sends + /// [`ClientEvent`]s to the provided sender. Returns when the network + /// event channel closes or the sender is dropped. + pub async fn run(mut self, tx: futures_mpsc::UnboundedSender) { + use futures::StreamExt; + + // Profile re-broadcast schedule: 3s, 6s, 10s, 20s after connect. + let profile_delays = [3000u32, 3000, 4000, 10000]; + let mut profile_idx = 0; + let mut profile_timer = Box::pin(async { + #[cfg(target_arch = "wasm32")] + gloo_timers::future::TimeoutFuture::new(profile_delays[0]).await; + #[cfg(not(target_arch = "wasm32"))] + tokio::time::sleep(std::time::Duration::from_millis(profile_delays[0] as u64)).await; + }) + .fuse(); + + loop { + futures::select! { + net_event = self.event_rx.next() => { + let Some(net_event) = net_event else { + // Network channel closed — shut down gracefully. + break; + }; + + // Drain any additional ready events for batching. + let mut batch = vec![net_event]; + while let Ok(Some(more)) = self.event_rx.try_next() { + batch.push(more); + } + + // Process the batch. + let client_events = self.process_batch(batch); + for event in client_events { + if tx.unbounded_send(event).is_err() { + // Receiver dropped — shut down. + return; + } + } + } + _ = profile_timer => { + // Broadcast profile at this interval. + self.broadcast_profile(); + profile_idx += 1; + if profile_idx < profile_delays.len() { + profile_timer = Box::pin(async move { + #[cfg(target_arch = "wasm32")] + gloo_timers::future::TimeoutFuture::new( + profile_delays[profile_idx] + ).await; + #[cfg(not(target_arch = "wasm32"))] + tokio::time::sleep(std::time::Duration::from_millis( + profile_delays[profile_idx] as u64 + )).await; + }).fuse(); + } else { + // All re-broadcasts done. Replace with a future that never resolves. + profile_timer = Box::pin(futures::future::pending()).fuse(); + } + } + complete => break, + } + } + } +} +``` + +- [ ] **Step 2: Move `poll()` logic into `process_batch()`** + +Move the body of the old `Client::poll()` into `ClientEventLoop::process_batch()`: + +```rust +impl ClientEventLoop { + fn process_batch( + &self, + net_events: Vec, + ) -> Vec { + let mut shared = self.shared.borrow_mut(); + let mut events = Vec::new(); + + for net_event in net_events { + match net_event { + // ... exact same match arms as the old poll(), but using + // `shared.state`, `shared.identity`, etc. instead of `self.state` + } + } + + events + } +} +``` + +Also move `emit_client_events_for()` to `ClientEventLoop` (it's only called during event processing). + +- [ ] **Step 3: Add `broadcast_profile()` helper** + +```rust +impl ClientEventLoop { + fn broadcast_profile(&self) { + let shared = self.shared.borrow(); + if !shared.connected_subscribed { + return; + } + let saved = storage::load_profile().unwrap_or_default(); + if !saved.display_name.is_empty() { + // ClientEventLoop doesn't own cmd_tx, so we need it. + // Option A: store cmd_tx clone in the event loop. + // Option B: send profile through shared state. + } + } +} +``` + +Note: The event loop needs to send network commands for profile re-broadcast. Add a `cmd_tx: futures_mpsc::UnboundedSender` field to `ClientEventLoop`. The constructor clones it from the same sender used by `ClientHandle`. + +Update `ClientEventLoop` struct: + +```rust +pub struct ClientEventLoop { + pub(crate) shared: Rc>, + pub(crate) event_rx: futures_mpsc::UnboundedReceiver, + pub(crate) cmd_tx: futures_mpsc::UnboundedSender, +} +``` + +Update `new()` to clone `cmd_tx` for the event loop. + +- [ ] **Step 4: Verify compilation** + +Run: `cargo check -p willow-client` + +Expected: Compiles with zero errors. + +- [ ] **Step 5: Commit** + +```bash +git add crates/client/src/lib.rs +git commit -m "feat: implement ClientEventLoop with async event processing + +Replaces the synchronous poll() method with an async run() loop. +Events are awaited via futures::select!, processed in batches, +and forwarded as ClientEvents. Profile re-broadcast uses real +timers instead of tick counting." +``` + +--- + +### Task 4: Fix client tests + +**Files:** +- Modify: `crates/client/src/lib.rs` (test module) + +- [ ] **Step 1: Rewrite `test_client()` as `test_handle()`** + +```rust +pub(crate) fn test_handle() -> (ClientHandle, futures_mpsc::UnboundedReceiver) { + let identity = Identity::generate(); + let (cmd_tx, cmd_rx) = futures_mpsc::unbounded(); + let (_event_tx, event_rx) = futures_mpsc::unbounded(); + + let mut state = ClientState::default(); + + // Create a minimal server (same as before). + let mut server = willow_channel::Server::new("Test Server", identity.peer_id()); + let ch_id = server + .create_channel("general", willow_channel::ChannelKind::Text) + .unwrap(); + let topic = util::make_topic(&server, "general"); + + let server_id = server.id.to_string(); + let mut topic_map = HashMap::new(); + let mut keys = HashMap::new(); + + if let Some(key) = server.channel_key(&ch_id) { + keys.insert(topic.clone(), key.clone()); + } + topic_map.insert(topic, ("general".to_string(), ch_id)); + + let ctx = ServerContext { + server, + topic_map, + keys, + unread: HashMap::new(), + }; + state.servers.insert(server_id.clone(), ctx); + state.active_server = Some(server_id.clone()); + state.chat.current_channel = "general".to_string(); + + // Initialize event state. + let owner = identity.peer_id().to_string(); + state.event_state = willow_state::ServerState::new( + server_id, "Test Server".to_string(), owner, + ); + + let shared = Rc::new(RefCell::new(SharedState { + state, + identity, + config: ClientConfig { persistence: false, ..Default::default() }, + connected: false, + connected_subscribed: false, + typing_peers: HashMap::new(), + voice_participants: HashMap::new(), + active_voice_channel: None, + voice_muted: false, + voice_deafened: false, + state_verification_results: HashMap::new(), + last_typing_sent_ms: 0, + })); + + let handle = ClientHandle { + shared, + cmd_tx, + deferred_channels: None, + }; + + (handle, cmd_rx) +} +``` + +- [ ] **Step 2: Find-and-replace `test_client()` with `test_handle()` in all tests** + +There are ~70 call sites. Replace: +- `let (mut client, cmd_rx) = test_client();` → `let (handle, cmd_rx) = test_handle();` +- `let (mut client, _) = test_client();` → `let (handle, _) = test_handle();` +- `let (client, _) = test_client();` → `let (handle, _) = test_handle();` + +Then replace `client.method()` with `handle.method()` in each test. Since `ClientHandle` methods take `&self` instead of `&mut self`, remove `mut` from handle bindings where it was only needed for `&mut self`. + +**Important:** Some tests directly access struct fields like `client.typing_peers.insert(...)`, `client.state.chat.current_channel`, `client.identity`, etc. These cannot be mechanically replaced — they need `handle.shared.borrow_mut().typing_peers.insert(...)` or equivalent. Search for all `client.` accesses that are NOT method calls (no parentheses) and adapt them to borrow through `shared`. + +- [ ] **Step 3: Run all client tests** + +Run: `cargo test -p willow-client` + +Expected: All 93 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/client/src/lib.rs +git commit -m "test: migrate client tests to ClientHandle API + +Replace test_client() with test_handle(). All 93 tests pass +with the new ClientHandle interface." +``` + +--- + +### Task 5: Create web crate state module and update dependencies + +**Files:** +- Modify: `crates/web/Cargo.toml` +- Create: `crates/web/src/state.rs` +- Modify: `crates/web/src/app.rs` (add `mod state;`) + +- [ ] **Step 1: Update web crate Cargo.toml** + +Add `futures` to `[dependencies]`: + +```toml +futures = "0.3" +``` + +Move `gloo-timers` from `[dev-dependencies]` to `[dependencies]`: + +```toml +gloo-timers = { version = "0.3", features = ["futures"] } +``` + +- [ ] **Step 2: Create `state.rs` with all AppState sub-structs** + +Create `crates/web/src/state.rs`: + +```rust +use std::collections::HashMap; + +use leptos::prelude::*; +use willow_client::DisplayMessage; + +/// Per-channel UI state. Extensible for future needs (drafts, scroll pos). +#[derive(Clone, Default, PartialEq)] +pub struct ChannelViewState { + pub typing: Vec, +} + +// ── Read signals (provided via context) ────────────────────────────── + +#[derive(Clone, Copy)] +pub struct AppState { + pub chat: ChatState, + pub network: NetworkState, + pub server: ServerState, + pub ui: UiState, + pub voice: VoiceState, +} + +#[derive(Clone, Copy)] +pub struct ChatState { + pub messages: ReadSignal>, + pub current_channel: ReadSignal, + pub channels: ReadSignal>, + pub replying_to: ReadSignal>, + pub editing: ReadSignal>, + pub pinned_messages: ReadSignal>, + pub pin_labels: ReadSignal>, + pub channel_views: ReadSignal>, +} + +#[derive(Clone, Copy)] +pub struct NetworkState { + pub peers: ReadSignal>, + pub peer_count: ReadSignal, + pub peer_id: ReadSignal, + pub connection_status: ReadSignal, + pub loading: ReadSignal, +} + +#[derive(Clone, Copy)] +pub struct ServerState { + pub servers: ReadSignal>, + pub active_server_id: ReadSignal, + pub active_server_name: ReadSignal, + pub unread: ReadSignal>, + pub roles: ReadSignal)>>, + pub display_name: ReadSignal, +} + +#[derive(Clone, Copy)] +pub struct UiState { + pub show_settings: ReadSignal, + pub show_server_settings: ReadSignal, + pub show_sidebar: ReadSignal, + pub show_members: ReadSignal, + pub show_add_server: ReadSignal, + pub show_pinned: ReadSignal, +} + +#[derive(Clone, Copy)] +pub struct VoiceState { + pub voice_channel: ReadSignal>, + pub voice_muted: ReadSignal, + pub voice_deafened: ReadSignal, + pub voice_participants_map: ReadSignal>>, + pub voice_channel_name: ReadSignal, +} + +// ── Write signals (NOT in context — held by event processing) ──────── + +#[derive(Clone, Copy)] +pub struct AppWriteSignals { + pub chat: ChatWriteSignals, + pub network: NetworkWriteSignals, + pub server: ServerWriteSignals, + pub ui: UiWriteSignals, + pub voice: VoiceWriteSignals, +} + +#[derive(Clone, Copy)] +pub struct ChatWriteSignals { + pub set_messages: WriteSignal>, + pub set_current_channel: WriteSignal, + pub set_channels: WriteSignal>, + pub set_replying_to: WriteSignal>, + pub set_editing: WriteSignal>, + pub set_pinned_messages: WriteSignal>, + pub set_pin_labels: WriteSignal>, + pub set_channel_views: WriteSignal>, +} + +#[derive(Clone, Copy)] +pub struct NetworkWriteSignals { + pub set_peers: WriteSignal>, + pub set_peer_count: WriteSignal, + pub set_peer_id: WriteSignal, + pub set_connection_status: WriteSignal, + pub set_loading: WriteSignal, +} + +#[derive(Clone, Copy)] +pub struct ServerWriteSignals { + pub set_servers: WriteSignal>, + pub set_active_server_id: WriteSignal, + pub set_active_server_name: WriteSignal, + pub set_unread: WriteSignal>, + pub set_roles: WriteSignal)>>, + pub set_display_name: WriteSignal, +} + +#[derive(Clone, Copy)] +pub struct UiWriteSignals { + pub set_show_settings: WriteSignal, + pub set_show_server_settings: WriteSignal, + pub set_show_sidebar: WriteSignal, + pub set_show_members: WriteSignal, + pub set_show_add_server: WriteSignal, + pub set_show_pinned: WriteSignal, +} + +#[derive(Clone, Copy)] +pub struct VoiceWriteSignals { + pub set_voice_channel: WriteSignal>, + pub set_voice_muted: WriteSignal, + pub set_voice_deafened: WriteSignal, + pub set_voice_participants_map: WriteSignal>>, + pub set_voice_channel_name: WriteSignal, +} + +/// Create all signal pairs and return the read/write halves. +pub fn create_signals() -> (AppState, AppWriteSignals) { + let (messages, set_messages) = signal(Vec::::new()); + let (current_channel, set_current_channel) = signal(String::from("general")); + let (channels, set_channels) = signal(Vec::::new()); + let (replying_to, set_replying_to) = signal(Option::::None); + let (editing, set_editing) = signal(Option::::None); + let (pinned_messages, set_pinned_messages) = signal(Vec::::new()); + let (pin_labels, set_pin_labels) = signal(HashMap::::new()); + let (channel_views, set_channel_views) = signal(HashMap::::new()); + + let (peers, set_peers) = signal(Vec::<(String, String, bool)>::new()); + let (peer_count, set_peer_count) = signal(0usize); + let (peer_id, set_peer_id) = signal(String::new()); + let (connection_status, set_connection_status) = signal("connecting".to_string()); + let (loading, set_loading) = signal(true); + + let (servers, set_servers) = signal(Vec::<(String, String)>::new()); + let (active_server_id, set_active_server_id) = signal(String::new()); + let (active_server_name, set_active_server_name) = signal(String::new()); + let (unread, set_unread) = signal(HashMap::::new()); + let (roles, set_roles) = signal(Vec::<(String, String, Vec)>::new()); + let (display_name, set_display_name) = signal(String::new()); + + let (show_settings, set_show_settings) = signal(false); + let (show_server_settings, set_show_server_settings) = signal(false); + let (show_sidebar, set_show_sidebar) = signal(false); + let (show_members, set_show_members) = signal(false); + let (show_add_server, set_show_add_server) = signal(false); + let (show_pinned, set_show_pinned) = signal(false); + + let (voice_channel, set_voice_channel) = signal(Option::::None); + let (voice_muted, set_voice_muted) = signal(false); + let (voice_deafened, set_voice_deafened) = signal(false); + let (voice_participants_map, set_voice_participants_map) = + signal(HashMap::>::new()); + let (voice_channel_name, set_voice_channel_name) = signal(String::new()); + + let app_state = AppState { + chat: ChatState { + messages, current_channel, channels, replying_to, editing, + pinned_messages, pin_labels, channel_views, + }, + network: NetworkState { + peers, peer_count, peer_id, connection_status, loading, + }, + server: ServerState { + servers, active_server_id, active_server_name, unread, roles, display_name, + }, + ui: UiState { + show_settings, show_server_settings, show_sidebar, show_members, + show_add_server, show_pinned, + }, + voice: VoiceState { + voice_channel, voice_muted, voice_deafened, voice_participants_map, + voice_channel_name, + }, + }; + + let write_signals = AppWriteSignals { + chat: ChatWriteSignals { + set_messages, set_current_channel, set_channels, set_replying_to, + set_editing, set_pinned_messages, set_pin_labels, set_channel_views, + }, + network: NetworkWriteSignals { + set_peers, set_peer_count, set_peer_id, set_connection_status, set_loading, + }, + server: ServerWriteSignals { + set_servers, set_active_server_id, set_active_server_name, set_unread, + set_roles, set_display_name, + }, + ui: UiWriteSignals { + set_show_settings, set_show_server_settings, set_show_sidebar, + set_show_members, set_show_add_server, set_show_pinned, + }, + voice: VoiceWriteSignals { + set_voice_channel, set_voice_muted, set_voice_deafened, + set_voice_participants_map, set_voice_channel_name, + }, + }; + + (app_state, write_signals) +} +``` + +- [ ] **Step 3: Add `mod state;` to main.rs and update the type alias** + +In `crates/web/src/main.rs`, add `mod state;` to the module declarations. + +In `crates/web/src/app.rs`, update the `ClientHandle` type alias: + +```rust +// Before: +pub type ClientHandle = SendWrapper>>; + +// After: +/// Wrapper around `ClientHandle` that is `Send` for single-threaded WASM. +pub type WebClientHandle = SendWrapper; +``` + +Keep `VoiceManagerHandle` as-is. + +- [ ] **Step 4: Verify compilation** + +Run: `cargo check -p willow-web` + +Expected: Compiles (state.rs is defined but not yet used — that's fine). + +- [ ] **Step 5: Commit** + +```bash +git add crates/web/ +git commit -m "feat: add web state module with AppState and AppWriteSignals + +Defines structured signal containers for context-based state management. +create_signals() produces all 30 signal pairs grouped into sub-structs." +``` + +--- + +### Task 6: Create event processing and handlers modules + +**Files:** +- Create: `crates/web/src/event_processing.rs` +- Create: `crates/web/src/handlers.rs` +- Modify: `crates/web/src/voice.rs` +- Modify: `crates/web/src/app.rs` (add mod declarations) + +- [ ] **Step 1: Create `event_processing.rs`** + +Create `crates/web/src/event_processing.rs`. Extract the body of the current poll loop (app.rs lines 155-324) into `process_event_batch()`: + +```rust +use std::collections::HashMap; + +use willow_client::{ClientEvent, VoiceSignalPayload}; + +use crate::app::{WebClientHandle, VoiceManagerHandle}; +use crate::state::AppWriteSignals; +use crate::voice::handle_voice_event; + +/// Process a batch of ClientEvents and update signals. +/// +/// Uses the same flag-based batching as the original poll loop: +/// collect flags from all events, then do one signal update pass. +pub fn process_event_batch( + events: &[ClientEvent], + handle: &WebClientHandle, + state: &AppState, + write: &AppWriteSignals, + voice_manager: &VoiceManagerHandle, +) { + let mut needs_msg_refresh = false; + let mut needs_peer_refresh = false; + let mut needs_channel_refresh = false; + + for event in events { + match event { + ClientEvent::MessageReceived { .. } => { + needs_msg_refresh = true; + } + ClientEvent::MessageEdited { .. } + | ClientEvent::MessageDeleted { .. } + | ClientEvent::ReactionAdded { .. } + | ClientEvent::SyncCompleted { .. } => { + needs_msg_refresh = true; + } + ClientEvent::PeerConnected(_) => { + needs_peer_refresh = true; + write.network.set_connection_status.set("connected".to_string()); + write.network.set_loading.set(false); + } + ClientEvent::PeerDisconnected(_) => { + needs_peer_refresh = true; + } + ClientEvent::Listening(_) => {} + ClientEvent::ChannelCreated(_) | ClientEvent::ChannelDeleted(_) => { + needs_channel_refresh = true; + } + ClientEvent::ProfileUpdated { .. } => { + let c = handle.borrow(); + write.server.set_display_name.set(c.display_name()); + needs_peer_refresh = true; + } + ClientEvent::VoiceJoined { .. } + | ClientEvent::VoiceLeft { .. } + | ClientEvent::VoiceSignal { .. } => { + handle_voice_event(event, handle, state, write, voice_manager); + } + _ => {} + } + } + + let current_channel = state.chat.current_channel.get_untracked(); + let c = handle.borrow(); + if needs_msg_refresh { + // Same smart-diff logic as the original poll loop. + let new_msgs = c.messages(¤t_channel); + let old_msgs = state.chat.messages.get_untracked(); + // ... change detection ... + write.chat.set_messages.set(new_msgs); + + // Refresh pinned, pin labels, unread. + write.chat.set_pinned_messages.set(c.pinned_messages(¤t_channel)); + // ... pin labels ... + // ... unread map ... + } + if needs_peer_refresh { + let peer_list = c.server_members(); + let count = peer_list.iter().filter(|(_, _, online)| *online).count(); + write.network.set_peers.set(peer_list); + write.network.set_peer_count.set(count); + if count > 0 { + write.network.set_connection_status.set("connected".to_string()); + } else { + write.network.set_connection_status.set("connecting".to_string()); + } + } + if needs_channel_refresh { + write.chat.set_channels.set(c.channels()); + write.server.set_roles.set(extract_roles(&c)); + } + if needs_msg_refresh || needs_peer_refresh { + write.server.set_roles.set(extract_roles(&c)); + } +} + +/// Refresh all signals from client state. Used after server create/join/switch. +pub fn refresh_all_signals(handle: &WebClientHandle, write: &AppWriteSignals) { + let c = handle.borrow(); + write.server.set_servers.set(c.server_list()); + write.chat.set_channels.set(c.channels()); + write.network.set_peer_id.set(c.peer_id()); + write.server.set_display_name.set(c.display_name()); + write.server.set_roles.set(extract_roles(&c)); + if let Some(id) = c.active_server_id() { + write.server.set_active_server_id.set(id.to_string()); + } + write.server.set_active_server_name.set(c.active_server_name()); + let ch = c.channels().first().cloned().unwrap_or("general".to_string()); + write.chat.set_current_channel.set(ch.clone()); + write.chat.set_messages.set(c.messages(&ch)); + write.ui.set_show_settings.set(false); + write.ui.set_show_server_settings.set(false); + write.ui.set_show_add_server.set(false); +} + +/// Extract roles from client state. +/// Uses the `roles_data()` accessor on ClientHandle which borrows internally. +pub fn extract_roles(handle: &WebClientHandle) -> Vec<(String, String, Vec)> { + handle.borrow().roles_data() +} +``` + +Note: The exact logic inside `process_event_batch` is lifted directly from the current poll loop in `app.rs:155-324`. The code above shows the structure — the full implementation copies each branch verbatim from the existing code, adapting signal names (`set_messages` → `write.chat.set_messages`, etc.). + +- [ ] **Step 2: Add voice event helper to `voice.rs`** + +Add to `crates/web/src/voice.rs`: + +```rust +use willow_client::ClientEvent; +use crate::app::{WebClientHandle, VoiceManagerHandle}; +use crate::state::AppWriteSignals; + +/// Handle voice-related ClientEvents from the event processing batch. +pub fn handle_voice_event( + event: &ClientEvent, + _handle: &WebClientHandle, + state: &AppState, + write: &AppWriteSignals, + voice_manager: &VoiceManagerHandle, +) { + match event { + ClientEvent::VoiceJoined { channel_id, peer_id } => { + write.voice.set_voice_participants_map.update(|m| { + let participants = m.entry(channel_id.clone()).or_default(); + if !participants.contains(peer_id) { + participants.push(peer_id.clone()); + } + }); + // If we're in this channel, create offer to new peer. + let current_vc = state.voice.voice_channel.get_untracked(); + if current_vc.as_deref() == Some(channel_id) { + let vm = voice_manager.clone(); + let pid = peer_id.clone(); + wasm_bindgen_futures::spawn_local( + crate::voice::create_offer(vm, pid) + ); + } + } + ClientEvent::VoiceLeft { channel_id, peer_id } => { + write.voice.set_voice_participants_map.update(|m| { + if let Some(v) = m.get_mut(channel_id) { + v.retain(|p| p != peer_id); + } + }); + voice_manager.borrow_mut().close_connection(peer_id); + } + ClientEvent::VoiceSignal { from_peer, signal, .. } => { + // Same spawn_local pattern as current app.rs + let vm = voice_manager.clone(); + let from = from_peer.clone(); + match signal { + willow_client::VoiceSignalPayload::Offer(sdp) => { + wasm_bindgen_futures::spawn_local( + crate::voice::handle_offer(vm, from, sdp.clone()) + ); + } + willow_client::VoiceSignalPayload::Answer(sdp) => { + wasm_bindgen_futures::spawn_local( + crate::voice::handle_answer(vm, from, sdp.clone()) + ); + } + willow_client::VoiceSignalPayload::IceCandidate(json) => { + let _ = vm.borrow().handle_ice_candidate(&from, json); + } + } + } + _ => {} + } +} +``` + +Rename the existing `handle_voice_create_offer`, `handle_voice_offer`, `handle_voice_answer` functions in `app.rs` to `create_offer`, `handle_offer`, `handle_answer` and move to `voice.rs` (or make them `pub` so the new module can call them). + +- [ ] **Step 3: Create `handlers.rs`** + +Create `crates/web/src/handlers.rs`: + +```rust +use std::collections::HashMap; + +use leptos::prelude::*; +use willow_client::DisplayMessage; + +use crate::app::WebClientHandle; +use crate::event_processing::{extract_roles, refresh_all_signals}; +use crate::state::{AppState, AppWriteSignals}; + +// **Important:** In Leptos 0.7, `WriteSignal` does NOT have `get_untracked()`. +// Only `ReadSignal` does. All handlers that need to read current values must +// use the `AppState` (read signals), not `AppWriteSignals` (write signals). +// Handler constructors take BOTH `AppState` and `AppWriteSignals`. + +/// Send message or reply handler. +pub fn make_send_handler( + handle: WebClientHandle, + state: AppState, + write: AppWriteSignals, +) -> impl Fn(String) + Clone { + move |body: String| { + let ch = state.chat.current_channel.get_untracked(); + let c = handle.borrow(); + let replying = state.chat.replying_to.get_untracked(); + if let Some(reply_msg) = replying { + let _ = c.send_reply(&ch, &reply_msg.id, &body); + write.chat.set_replying_to.set(None); + } else { + let _ = c.send_message(&ch, &body); + } + write.chat.set_messages.set(c.messages(&ch)); + } +} + +/// Edit message handler. +pub fn make_edit_handler( + handle: WebClientHandle, + state: AppState, + write: AppWriteSignals, +) -> impl Fn((String, String)) + Clone { + move |(message_id, new_body): (String, String)| { + let ch = state.chat.current_channel.get_untracked(); + let c = handle.borrow(); + let _ = c.edit_message(&ch, &message_id, &new_body); + write.chat.set_editing.set(None); + write.chat.set_messages.set(c.messages(&ch)); + } +} + +/// Delete message handler. +pub fn make_delete_handler( + handle: WebClientHandle, + state: AppState, + write: AppWriteSignals, +) -> impl Fn(DisplayMessage) + Clone { + move |msg: DisplayMessage| { + let ch = state.chat.current_channel.get_untracked(); + let c = handle.borrow(); + let _ = c.delete_message(&ch, &msg.id); + write.chat.set_messages.set(c.messages(&ch)); + } +} + +/// React handler. +pub fn make_react_handler( + handle: WebClientHandle, + state: AppState, + write: AppWriteSignals, +) -> impl Fn((DisplayMessage, String)) + Clone { + move |(msg, emoji): (DisplayMessage, String)| { + let ch = state.chat.current_channel.get_untracked(); + let c = handle.borrow(); + let _ = c.react(&ch, &msg.id, &emoji); + write.chat.set_messages.set(c.messages(&ch)); + } +} + +/// Channel switch handler. +pub fn make_channel_click_handler( + handle: WebClientHandle, + state: AppState, + write: AppWriteSignals, +) -> impl Fn(String) + Clone { + move |name: String| { + write.chat.set_current_channel.set(name.clone()); + write.ui.set_show_sidebar.set(false); + write.ui.set_show_pinned.set(false); + { + let c = handle.borrow(); + write.chat.set_messages.set(c.messages(&name)); + write.chat.set_pinned_messages.set(c.pinned_messages(&name)); + let mut labels = HashMap::new(); + for msg in c.messages(&name) { + let label = if c.is_pinned(&name, &msg.id) { "Unpin" } else { "Pin" }; + labels.insert(msg.id.clone(), label.to_string()); + } + write.chat.set_pin_labels.set(labels); + } + handle.borrow().switch_channel(&name); + write.server.set_unread.update(|m| { m.remove(&name); }); + } +} + +/// Server switch handler. +pub fn make_server_click_handler( + handle: WebClientHandle, + _state: AppState, + write: AppWriteSignals, +) -> impl Fn(String) + Clone { + move |id: String| { + handle.borrow().switch_server(&id); + refresh_all_signals(&handle, &write); + } +} + +/// Pin/unpin handler. +pub fn make_pin_handler( + handle: WebClientHandle, + state: AppState, + write: AppWriteSignals, +) -> impl Fn(DisplayMessage) + Clone { + move |msg: DisplayMessage| { + let ch = state.chat.current_channel.get_untracked(); + let c = handle.borrow(); + if c.is_pinned(&ch, &msg.id) { + let _ = c.unpin_message(&ch, &msg.id); + } else { + let _ = c.pin_message(&ch, &msg.id); + } + write.chat.set_pinned_messages.set(c.pinned_messages(&ch)); + let mut labels = HashMap::new(); + for m in c.messages(&ch) { + let label = if c.is_pinned(&ch, &m.id) { "Unpin" } else { "Pin" }; + labels.insert(m.id.clone(), label.to_string()); + } + write.chat.set_pin_labels.set(labels); + } +} +``` + +- [ ] **Step 4: Add mod declarations to `main.rs`** + +The web crate root is `crates/web/src/main.rs`. Add the new modules there (not in `app.rs`): + +```rust +mod app; +mod components; +mod event_processing; +mod handlers; +mod state; +pub(crate) mod util; +pub mod voice; +``` + +- [ ] **Step 5: Verify compilation** + +Run: `cargo check -p willow-web` + +Expected: Compiles. The new modules are defined. The old App component still exists and works for now. + +- [ ] **Step 6: Commit** + +```bash +git add crates/web/src/ +git commit -m "feat: add event_processing and handlers modules + +Extract poll loop logic into process_event_batch(). +Extract action handlers into named constructor functions. +Add voice event handling helper to voice.rs." +``` + +--- + +### Task 7: Rewrite App component + +**Files:** +- Modify: `crates/web/src/app.rs` + +This is the biggest single change. The 800-line `App` function is gutted and rebuilt using the new modules. + +- [ ] **Step 1: Rewrite App component** + +Replace the body of the `App` component in `crates/web/src/app.rs`. The new version: + +1. Creates `ClientHandle` + `ClientEventLoop` via `willow_client::new()` +2. Calls `handle.connect()` +3. Creates signals via `state::create_signals()` +4. Populates initial state via `event_processing::refresh_all_signals()` +5. Creates `VoiceManager` +6. Provides context: `AppState`, `WebClientHandle`, `VoiceManagerHandle` +7. Spawns event loop task +8. Spawns signal updater task +9. Spawns typing expiry timer +10. Sets loading timeout +11. Creates handlers via `handlers::make_*` +12. Renders the layout (same view tree as before, but with context instead of props) + +The `init_theme()`, `toggle_theme()`, `LOADING_TIMEOUT_MS`, and `DEFAULT_RELAY` constants stay in `app.rs`. + +Remove the old `ClientHandle` type alias. Add `WebClientHandle`: + +```rust +pub type WebClientHandle = SendWrapper; +``` + +The view template stays structurally the same but components lose most props (they pull from context). Components that still need callbacks receive them as `Callback` props. + +- [ ] **Step 2: Spawn the event loop and signal updater** + +Inside `App`, after `provide_context`: + +```rust +// Spawn the client event loop. +let (client_event_tx, client_event_rx) = futures::channel::mpsc::unbounded(); +wasm_bindgen_futures::spawn_local(event_loop.run(client_event_tx)); + +// Spawn the signal updater. +let updater_handle = handle.clone(); +let updater_state = app_state; // AppState is Copy (all fields are Copy signals) +let updater_write = write_signals; +let updater_vm = voice_manager.clone(); +wasm_bindgen_futures::spawn_local(async move { + use futures::StreamExt; + let mut rx = client_event_rx; + while let Some(event) = rx.next().await { + let mut batch = vec![event]; + while let Ok(Some(more)) = rx.try_next() { + batch.push(more); + } + event_processing::process_event_batch( + &batch, &updater_handle, &updater_state, &updater_write, &updater_vm, + ); + } +}); +``` + +- [ ] **Step 3: Spawn typing expiry timer** + +```rust +// Typing indicator expiry timer (~2s). +let typing_handle = handle.clone(); +let typing_write = write_signals; +wasm_bindgen_futures::spawn_local(async move { + use futures::StreamExt; + let mut interval = gloo_timers::future::IntervalStream::new(2_000); + while interval.next().await.is_some() { + let c = typing_handle.borrow(); // Note: typing_in() needs mut, so use borrow_mut() if it prunes stale entries + let mut views = typing_write.chat.set_channel_views.get_untracked(); // Read current value + let mut changed = false; + for ch_name in c.channels() { + let typers = c.typing_in(&ch_name); + let current = views.get(&ch_name).map(|v| &v.typing); + if current != Some(&typers) { + views.insert(ch_name, crate::state::ChannelViewState { typing: typers }); + changed = true; + } + } + if changed { + typing_write.chat.set_channel_views.set(views); + } + } +}); +``` + +- [ ] **Step 4: Verify compilation** + +Run: `cargo check -p willow-web` + +Expected: Compiles. May have warnings about unused old code — that's fine, it will be cleaned up next. + +- [ ] **Step 5: Commit** + +```bash +git add crates/web/src/app.rs +git commit -m "refactor: rewrite App component with context-based state + +Replace 50ms poll loop with spawn_local event processing. +Replace 30 prop-drilled signals with AppState context. +App is now ~80 lines of setup + layout." +``` + +--- + +### Task 8: Migrate components to context + +**Files:** +- Modify: `crates/web/src/components/sidebar.rs` +- Modify: `crates/web/src/components/member_list.rs` +- Modify: `crates/web/src/components/welcome.rs` +- Modify: `crates/web/src/components/add_server.rs` +- Modify: `crates/web/src/components/server_settings.rs` +- Modify: `crates/web/src/components/settings.rs` +- Modify: `crates/web/src/components/file_share.rs` +- Modify: `crates/web/src/components/roles.rs` +- Modify: `crates/web/src/components/chat.rs` +- Modify: `crates/web/src/components/mod.rs` + +All 8 components that import `crate::app::ClientHandle` switch to `use_context`. + +- [ ] **Step 1: Update component imports** + +In each of the 8 component files, replace: + +```rust +// Before: +use crate::app::ClientHandle; + +// After: +use crate::app::WebClientHandle; +use crate::state::AppState; +``` + +- [ ] **Step 2: Remove `client: ClientHandle` from component props** + +For each component, remove the `client` prop and instead pull from context: + +```rust +// Before: +#[component] +pub fn Sidebar(client: ClientHandle, /* many other props */) -> impl IntoView { + +// After: +#[component] +pub fn Sidebar(/* only callback props that can't come from context */) -> impl IntoView { + let handle = use_context::().unwrap(); + let state = use_context::().unwrap(); +``` + +Replace `client.borrow()` with `handle.borrow()` and signal prop access with `state.chat.channels.get()` etc. + +- [ ] **Step 3: Remove signal props that now come from context** + +For example, `Sidebar` currently takes `channels`, `current_channel`, `unread`, `connection_status`, `peer_count`, `server_name`, etc. as props. Remove all of these. The component reads them from `state`: + +```rust +// Before (prop): +let chs = channels.get(); + +// After (context): +let chs = state.chat.channels.get(); +``` + +Keep callback props that are specific to the component's parent context (like `on_channel_click`, `on_voice_join`). + +- [ ] **Step 4: Update `chat.rs` (ChannelHeader, MessageList)** + +These components mostly receive signals as props. Replace with context reads. `MessageList` keeps its callback props (`on_message_click`, `on_edit`, `on_delete`, `on_react`, `on_pin`). + +- [ ] **Step 5: Update `app.rs` view template** + +Remove props from component invocations that are now provided via context. For example: + +```rust +// Before: + + +// After: + +``` + +- [ ] **Step 6: Update browser test helpers to provide context** + +Browser tests use `mount_test(|| view! { ... })` to render components in isolation. Components that now use `use_context::()` will panic if no context is provided. Update the test helper or individual tests to wrap component rendering with `provide_context`: + +```rust +fn mount_test_with_context(f: impl FnOnce() -> impl IntoView + 'static) { + // Create mock AppState with default signals + let (app_state, _write) = crate::state::create_signals(); + let handle = /* create a mock WebClientHandle with no-op channels */; + mount_test(move || { + provide_context(app_state); + provide_context(handle); + f() + }); +} +``` + +Not all 39 browser tests will need this — only tests that render components which use `use_context`. Tests that render pure components (like `MessageView` which only takes props) are unaffected. Update tests incrementally: run `just test-browser`, fix failures one at a time. + +- [ ] **Step 7: Clean up unused old code** + +Remove any remaining dead code from `app.rs`: +- Old `ClientHandle` type alias (replaced by `WebClientHandle`) +- Old `handle_voice_create_offer`, `handle_voice_offer`, `handle_voice_answer` functions (moved to `voice.rs`) +- Old `extract_roles` function (moved to `event_processing.rs`) + +- [ ] **Step 8: Verify compilation** + +Run: `cargo check -p willow-web` + +Expected: Compiles with zero errors and zero warnings. + +- [ ] **Step 9: Commit** + +```bash +git add crates/web/ +git commit -m "refactor: migrate all components to context-based state + +8 components switch from ClientHandle prop to use_context. +Signal props replaced with AppState context reads. +Components are simpler with 1-2 callback props instead of 15+." +``` + +--- + +### Task 9: Full verification + +**Files:** None (testing only) + +- [ ] **Step 1: Run client tests** + +Run: `cargo test -p willow-client` + +Expected: All 93 tests pass. + +- [ ] **Step 2: Check WASM compilation** + +Run: `just check-wasm` + +Expected: Compiles for `wasm32-unknown-unknown` with zero errors. + +- [ ] **Step 3: Run clippy** + +Run: `just clippy` + +Expected: Zero warnings. + +- [ ] **Step 4: Run formatter** + +Run: `just fmt` + +Expected: No changes (or fix any formatting issues). + +- [ ] **Step 5: Run full check** + +Run: `just check` + +Expected: All checks pass (fmt + clippy + test + WASM). + +- [ ] **Step 6: Run browser tests (if Firefox + geckodriver available)** + +Run: `just test-browser` + +Expected: All 39 browser tests pass. + +- [ ] **Step 7: Final commit if any fixes were needed** + +```bash +git add -A +git commit -m "fix: address lint and test issues from refactor" +``` diff --git a/docs/superpowers/specs/2026-03-24-async-client-ui-refactor-design.md b/docs/superpowers/specs/2026-03-24-async-client-ui-refactor-design.md new file mode 100644 index 00000000..2b34c346 --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-async-client-ui-refactor-design.md @@ -0,0 +1,353 @@ +# Async Client + UI Refactor Design + +## Problem + +The Willow web UI has two layers of polling that add latency and waste CPU: + +1. **WASM network loop** polls `cmd_rx.try_recv()` every 16ms via a `gloo_timers` interval to check for outbound commands. +2. **Leptos UI** polls `client.poll()` every 50ms via `set_interval` to drain inbound events. + +Both use `std::sync::mpsc` which has no async awareness, forcing timer-based polling. + +Additionally, the Leptos `App` component is an 800-line monolith that creates 30 loose signals, threads them as props through components, clones `Rc>` 14 times, and mixes event handling, voice WebRTC, state management, and layout in a single function. + +## Scope + +- **In scope:** `willow-client` crate (async channels, Client split), `willow-web` crate (UI state context, event-driven updates, App breakup). +- **Out of scope:** Bevy native app (`crates/app/`). The refactored Client API will break the Bevy integration. This is acceptable since Bevy is disabled. It can be re-adapted later if needed. +- **Out of scope:** Yew frontend. This design makes the client framework-agnostic, which will make a future Yew frontend straightforward, but building it is not part of this work. + +## Design + +### 1. Async Channels + +Replace `std::sync::mpsc` with `futures::channel::mpsc::unbounded` for both directions between the network and client. + +**Commands (UI -> Network):** + +- `ClientHandle` holds `UnboundedSender`. +- WASM network loop `select!`s on `cmd_rx.next()` directly, eliminating the 16ms tick timer. +- Commands are delivered instantly. + +**Events (Network -> UI):** + +- Network loop sends `NetworkEvent`s on `UnboundedSender`. +- `ClientEventLoop` awaits `event_rx.next()`, eliminating all polling. +- Events arrive the instant the network produces them. + +**`spawn_network()` signature change:** Both the WASM and native `spawn_network()` functions change from `std::sync::mpsc` types to `futures::channel::mpsc` types: + +```rust +// Before: +pub fn spawn_network( + identity: Identity, + event_tx: std::sync::mpsc::Sender, + cmd_rx: std::sync::mpsc::Receiver, + config: NetworkConfig, +) + +// After: +pub fn spawn_network( + identity: Identity, + event_tx: futures::channel::mpsc::UnboundedSender, + cmd_rx: futures::channel::mpsc::UnboundedReceiver, + config: NetworkConfig, +) +``` + +The WASM `run_network_wasm()` replaces the 16ms tick timer with `cmd_rx.next()` in its `futures::select!`. The native `run_network()` replaces the 16ms `tokio::time::sleep` poll with `cmd_rx.next()` in its `tokio::select!` (this requires wrapping the `futures::channel` receiver with `tokio_util::compat` or switching the native side to `tokio::sync::mpsc` behind a cfg gate). Since native is allowed to break, the simplest path is to update both to use `futures::channel::mpsc` and fix the native side minimally or cfg-gate it. + +**Deferred channels mechanism:** The current `DeferredPair` type alias (`Arc, Receiver)>>>`) changes to hold the `futures::channel::mpsc` halves. `ClientHandle::connect()` consumes the deferred pair and passes the network-side halves (`event_tx`, `cmd_rx`) to `spawn_network()`. + +**Profile re-broadcast:** Currently tick-counted (fires at ticks 60, 120, 200, 400 of the 50ms poll, roughly 3s/6s/10s/20s). Replace with actual timers inside the event loop via `futures::select!`. On WASM, use `gloo_timers::future::TimeoutFuture`. On native, use `tokio::time::sleep` behind `#[cfg]`. The existing `profile_broadcast_counter` field is removed. + +**`notification_tx` removal:** The existing `notification_tx: Option>` push mechanism is removed. The new `ClientEventLoop` -> `UnboundedSender` channel replaces it entirely. The `ClientNotification` enum is also removed. Its `EventApplied` and `StateChanged` variants were internal signaling that the event loop now handles implicitly (it processes events and emits `ClientEvent`s directly). No new `ClientEvent` variants are needed. + +**New dependencies in `willow-web`:** Add `futures` to `Cargo.toml` for `StreamExt` (`.next()`, `.try_next()`). Move `gloo-timers` from `[dev-dependencies]` to `[dependencies]` for the typing indicator interval timer. + +### 2. Client Split + +Split the monolithic `Client` struct into three pieces. + +**`SharedState`** contains all mutable state, wrapped in `Rc>`: + +```rust +pub struct SharedState { + pub state: ClientState, // servers, event_state, chat, profiles, message_db + pub identity: Identity, + pub config: ClientConfig, + pub connected: bool, + pub connected_subscribed: bool, + pub typing_peers: HashMap, + pub voice_participants: HashMap>, + pub active_voice_channel: Option, + pub voice_muted: bool, + pub voice_deafened: bool, + pub state_verification_results: HashMap, + pub last_typing_sent_ms: u64, +} +``` + +**`Rc` vs `Arc`:** The CLAUDE.md convention says "Use `Arc` everywhere — all types must be `Send + Sync`." This applies to library crates that must work on both native and WASM. Since this refactor intentionally breaks native and targets WASM only, `SharedState` uses `Rc>` (no `Send + Sync` needed on single-threaded WASM). When native support is re-added later, this can be swapped to `Arc>` behind a cfg gate or via a type alias. + +**`ClientHandle`** is a cloneable command interface for UI components: + +- Holds `Rc>` + `UnboundedSender`. +- Exposes all mutation methods: `send_message()`, `create_channel()`, `switch_server()`, `join_voice()`, etc. +- Exposes all read accessors: `messages()`, `channels()`, `peer_id()`, `display_name()`, etc. +- Has `connect()` which spawns the network task. The deferred channel halves (`event_tx`, `cmd_rx` for the network side) are stored on `ClientHandle` (not `SharedState`) since they are consumed once during `connect()`. +- Cloneable. Components get their own copy via context. + +**Optimistic updates:** `ClientHandle` mutation methods (like `send_message`, `create_channel`) update `SharedState` immediately (applying the event to event-sourced state, persisting) AND send the command/event to the network. This matches the current `Client` behavior where local state updates are synchronous. There is no latency gap between a user action and the UI reflecting it. + +**Migration scope:** The existing `Client` has approximately 40+ methods spanning ~1500 lines. These methods are mechanically moved to `ClientHandle`, changing `self.state` references to `self.shared.borrow()` / `self.shared.borrow_mut()` and `self.cmd_tx` references (which remain the same). This is the bulk of the implementation work but is mostly mechanical. + +**`ClientEventLoop`** is the exclusive event processor, not cloneable: + +- Holds `Rc>` + `UnboundedReceiver`. +- `async fn run(self, tx: UnboundedSender)` is the main loop. +- Awaits network events, processes them (applies to event-sourced state, persists, dedup), sends `ClientEvent`s out on the tx channel. This includes all event types currently handled in `poll()`: `EventReceived`, `SyncBatchReceived`, `PeerConnected/Disconnected`, `ProfileReceived`, `FileAnnounced`, `TypingReceived`, `VoiceJoinReceived/LeaveReceived/SignalReceived`, etc. +- Handles profile re-broadcast via timer arms in `futures::select!`. +- **Batching at this layer:** When a network event arrives, drains any additional ready events from `event_rx` via `try_next()` before processing. A single `SyncBatchReceived` may emit multiple `ClientEvent`s. All are sent on `tx` individually. + +**Error handling / shutdown:** When `event_rx` closes (network shut down), `run()` returns gracefully. When `tx` is closed (UI dropped the receiver), the event loop logs a warning and returns. No panics on channel closure. + +**Constructor:** + +```rust +pub fn new(config: ClientConfig) -> (ClientHandle, ClientEventLoop) +``` + +Both share the same `Rc>`. The handle borrows briefly for sync operations; the event loop borrows briefly during processing then releases before the next await. No conflicts on single-threaded WASM. + +The existing `Client` struct and `poll()` method are removed. + +**Naming:** The web crate currently defines `pub type ClientHandle = SendWrapper>>`. This type alias is removed. The new `willow_client::ClientHandle` replaces it. Since `ClientHandle` contains `Rc` (not `Send`), the web crate wraps it in `SendWrapper` for Leptos context: `pub type WebClientHandle = SendWrapper`. Components that currently import `crate::app::ClientHandle` switch to `crate::app::WebClientHandle`. + +### 3. UI State Context + +Replace 30 loose signals threaded as props with a structured `AppState` provided via Leptos `provide_context`. + +**`AppState`** is grouped into sub-structs (read-only halves): + +```rust +pub struct AppState { + pub chat: ChatState, + pub network: NetworkState, + pub server: ServerState, + pub ui: UiState, + pub voice: VoiceState, +} + +pub struct ChatState { + pub messages: ReadSignal>, + pub current_channel: ReadSignal, + pub channels: ReadSignal>, + pub replying_to: ReadSignal>, + pub editing: ReadSignal>, + pub pinned_messages: ReadSignal>, + pub pin_labels: ReadSignal>, + /// Per-channel view state (typing indicators, future: drafts, scroll pos). + pub channel_views: ReadSignal>, +} + +/// Per-channel UI state. Extensible for future per-channel needs +/// (draft text, scroll position, etc.). +#[derive(Clone, Default, PartialEq)] +pub struct ChannelViewState { + pub typing: Vec, +} + +pub struct NetworkState { + pub peers: ReadSignal>, + pub peer_count: ReadSignal, + pub peer_id: ReadSignal, + pub connection_status: ReadSignal, + pub loading: ReadSignal, +} + +pub struct ServerState { + pub servers: ReadSignal>, + pub active_server_id: ReadSignal, + pub active_server_name: ReadSignal, + pub unread: ReadSignal>, + pub roles: ReadSignal)>>, + pub display_name: ReadSignal, +} + +pub struct UiState { + pub show_settings: ReadSignal, + pub show_server_settings: ReadSignal, + pub show_sidebar: ReadSignal, + pub show_members: ReadSignal, + pub show_add_server: ReadSignal, + pub show_pinned: ReadSignal, +} + +pub struct VoiceState { + pub voice_channel: ReadSignal>, + pub voice_muted: ReadSignal, + pub voice_deafened: ReadSignal, + pub voice_participants_map: ReadSignal>>, + pub voice_channel_name: ReadSignal, +} +``` + +**`AppWriteSignals`** is a companion struct holding the `WriteSignal` halves. Not provided as context. Held only by the event processing layer and handler closures that need to write: + +```rust +pub struct AppWriteSignals { + pub chat: ChatWriteSignals, + pub network: NetworkWriteSignals, + pub server: ServerWriteSignals, + pub ui: UiWriteSignals, + pub voice: VoiceWriteSignals, +} + +pub struct ChatWriteSignals { + pub set_messages: WriteSignal>, + pub set_current_channel: WriteSignal, + pub set_channels: WriteSignal>, + pub set_replying_to: WriteSignal>, + pub set_editing: WriteSignal>, + pub set_pinned_messages: WriteSignal>, + pub set_pin_labels: WriteSignal>, + pub set_channel_views: WriteSignal>, +} + +pub struct NetworkWriteSignals { + pub set_peers: WriteSignal>, + pub set_peer_count: WriteSignal, + pub set_peer_id: WriteSignal, + pub set_connection_status: WriteSignal, + pub set_loading: WriteSignal, +} + +pub struct ServerWriteSignals { + pub set_servers: WriteSignal>, + pub set_active_server_id: WriteSignal, + pub set_active_server_name: WriteSignal, + pub set_unread: WriteSignal>, + pub set_roles: WriteSignal)>>, + pub set_display_name: WriteSignal, +} + +pub struct UiWriteSignals { + pub set_show_settings: WriteSignal, + pub set_show_server_settings: WriteSignal, + pub set_show_sidebar: WriteSignal, + pub set_show_members: WriteSignal, + pub set_show_add_server: WriteSignal, + pub set_show_pinned: WriteSignal, +} + +pub struct VoiceWriteSignals { + pub set_voice_channel: WriteSignal>, + pub set_voice_muted: WriteSignal, + pub set_voice_deafened: WriteSignal, + pub set_voice_participants_map: WriteSignal>>, + pub set_voice_channel_name: WriteSignal, +} +``` + +Components access read state via `use_context::()` and reach into sub-structs: `state.chat.messages`, `state.ui.show_sidebar`, etc. + +Components that need to write get access through `Callback` props or by pulling `WebClientHandle` from context (for mutations that go through the client). UI-only toggles (like `set_show_sidebar`) are provided via `Callback` props from the parent that holds `AppWriteSignals`. + +### 4. Event-Driven UI Updates + +Replace the `set_interval(50ms)` poll loop with two `spawn_local` tasks. + +**Task 1 — Event loop (runs in client crate logic):** + +```rust +spawn_local(event_loop.run(client_event_tx)); +``` + +Awaits `NetworkEvent`s, processes them, sends `ClientEvent`s on the channel. Batching at this layer: drains ready `NetworkEvent`s, processes them, emits individual `ClientEvent`s. + +**Task 2 — Signal updater (in web crate):** + +```rust +spawn_local(async move { + while let Some(event) = client_event_rx.next().await { + let mut batch = vec![event]; + while let Ok(Some(more)) = client_event_rx.try_next() { + batch.push(more); + } + process_event_batch(&batch, &handle, &write, &voice_manager); + } +}); +``` + +Batching at this layer: drains ready `ClientEvent`s so that signal updates happen once per batch, not once per event. The two batching layers are intentionally independent. The event loop batches `NetworkEvent` processing (one lock cycle). The signal updater batches `ClientEvent` consumption (one render cycle). A single `SyncBatchReceived` network event may produce many `ClientEvent`s that the signal updater collects into one batch. + +The signal update logic is extracted into a standalone function: + +```rust +fn process_event_batch( + events: &[ClientEvent], + handle: &WebClientHandle, + write: &AppWriteSignals, + voice_manager: &VoiceManagerHandle, +) +``` + +This uses the same flag-based approach the current poll loop does (`needs_msg_refresh`, `needs_peer_refresh`, `needs_channel_refresh`) but as a named function instead of a 170-line inline closure. + +**Voice events inside `process_event_batch`:** Voice events (`VoiceJoined`, `VoiceSignal`) require async WebRTC operations (creating offers, handling answers). The batch processor calls `wasm_bindgen_futures::spawn_local` for these, same as the current poll loop does. The batch processor is called from within a `spawn_local` context so this is valid. + +**Typing indicator refresh:** Currently checked every 50ms as a flat list for the active channel. Replace with per-channel tracking via the `ChannelViewState` map. A separate `spawn_local` runs a `gloo_timers::future::IntervalStream` at ~2s to expire stale typing entries and update the `channel_views` signal. Components look up `channel_views.get().get(¤t_channel)` to display typing for the active channel. Typing state only needs coarse-grained updates. + +### 5. App Component Breakup + +The 800-line `App` function splits into focused modules. + +**`app.rs` — App component (~50 lines):** + +- Creates `ClientHandle` + `ClientEventLoop` via `willow_client::new()`. +- Creates signal pairs, builds `AppState` + `AppWriteSignals`. +- Calls `provide_context` for `AppState`, `WebClientHandle`, `VoiceManagerHandle`. +- Spawns the async tasks (event loop, signal updater, typing timer). +- Renders the top-level layout shell. + +**`event_processing.rs` (~100 lines):** + +- `fn process_event_batch(...)` — the flag-based batch processor. +- `fn refresh_all_signals(...)` — full refresh used after server create/join/switch. +- `fn extract_roles(...)` — role extraction helper. + +**`handlers.rs` (~80 lines):** + +Handler constructors that return closures: + +- `fn make_send_handler(...)` — send message / reply. +- `fn make_edit_handler(...)` — edit message. +- `fn make_delete_handler(...)` — delete message. +- `fn make_react_handler(...)` — add/toggle reaction. +- `fn make_channel_click_handler(...)` — switch channel. +- `fn make_server_click_handler(...)` — switch server. +- `fn make_pin_handler(...)` — pin/unpin message. + +Components receive these as 1-2 `Callback` props instead of many signal props. + +**Voice handling** stays in `voice.rs`. Voice event matching (offer/answer/ICE) moves from the inline poll loop to `process_event_batch`, which calls into `voice.rs` helpers via `spawn_local`. + +**File structure after refactor:** + +``` +crates/web/src/ +├── app.rs — App component (setup + layout) +├── event_processing.rs — batch processing + refresh logic +├── handlers.rs — action handler constructors +├── voice.rs — VoiceManager (existing, gains event helpers) +├── main.rs — entry point (unchanged) +├── util.rs — existing utilities +└── components/ — existing components, props simplified +``` + +## Testing + +- **Browser tests** (`just test-browser`, 39 tests) validate component rendering and do not use `Client` directly. These must continue to pass with the new context-based component props. +- **Client library tests** (`just test-client`, 93 tests) create `Client` via `test_client()`. This helper is replaced with `test_handle()` which creates a `ClientHandle` + `SharedState` without a network connection (no `connect()` call, commands go to a no-op channel). The `test_handle()` function returns `(ClientHandle, UnboundedReceiver)` so tests can assert on commands sent. Test assertions change from `client.method()` to `handle.method()` — largely mechanical. +- **WASM compilation check** (`just check-wasm`) must pass.