From 9ccc73e71530e984ebdf367d15b78ed7486beaea Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Sun, 25 Jan 2026 10:27:17 -0600 Subject: [PATCH 1/2] fix(ui): refresh all rooms after PC suspension/wake to catch missed updates When a user's PC suspends and resumes, the River UI may miss contract updates that arrived while the page was hidden. Previously, the PageBecameVisible handler only triggered ProcessRooms which handles pending changes but doesn't fetch the latest state from the network. This adds a new RefreshAllRooms mechanism that: 1. Sends GET requests for all subscribed room contracts on wake 2. Fetches current network state to catch any missed updates 3. Merges the received state with local state This fixes the issue where users would open River after PC suspension and not see recent messages until they sent a new message. Also fixes a pre-existing compilation error in example_data.rs where Configuration.name was accessed directly instead of through the new Configuration.display.name path. Co-Authored-By: Claude Opus 4.5 --- .../app/freenet_api/freenet_synchronizer.rs | 50 +++++++++++++-- .../app/freenet_api/room_synchronizer.rs | 63 +++++++++++++++++++ ui/src/example_data.rs | 16 +++-- 3 files changed, 118 insertions(+), 11 deletions(-) diff --git a/ui/src/components/app/freenet_api/freenet_synchronizer.rs b/ui/src/components/app/freenet_api/freenet_synchronizer.rs index 658548ee..b0113afe 100644 --- a/ui/src/components/app/freenet_api/freenet_synchronizer.rs +++ b/ui/src/components/app/freenet_api/freenet_synchronizer.rs @@ -31,6 +31,9 @@ pub enum SynchronizerMessage { ConnectionLost, /// Sent when page becomes visible after being hidden (e.g., after sleep/wake) PageBecameVisible, + /// Sent to refresh all room states after reconnection (e.g., after sleep/wake) + /// This fetches current state for all rooms to catch any updates missed during suspension + RefreshAllRooms, ApiResponse(Result), AcceptInvitation { owner_vk: VerifyingKey, @@ -206,16 +209,53 @@ impl FreenetSynchronizer { error!("Failed to send Connect message after wake: {}", e); } } else { - // Connection appears active, trigger a room sync to verify - // This will fail fast if the connection is actually dead - info!("Connection appears active, triggering room sync to verify"); + // Connection appears active, but we may have missed updates during suspension. + // First verify connection with ProcessRooms, then refresh all rooms to + // catch any updates that arrived while the page was hidden/PC was suspended. + info!("Connection appears active, refreshing all rooms to catch missed updates"); if let Err(e) = - message_tx.unbounded_send(SynchronizerMessage::ProcessRooms) + message_tx.unbounded_send(SynchronizerMessage::RefreshAllRooms) { - error!("Failed to send ProcessRooms message after wake: {}", e); + error!("Failed to send RefreshAllRooms message after wake: {}", e); } } } + SynchronizerMessage::RefreshAllRooms => { + // Refresh all room states by sending GET requests + // This catches any updates missed during PC suspension or page being hidden + info!("Refreshing all rooms to catch missed updates"); + if !connection_manager.is_connected() { + info!( + "Connection not ready, deferring refresh and attempting to connect" + ); + if let Err(e) = message_tx.unbounded_send(SynchronizerMessage::Connect) + { + error!("Failed to send Connect message: {}", e); + } + continue; + } + if let Err(e) = response_handler + .get_room_synchronizer_mut() + .refresh_all_rooms() + .await + { + error!("Error refreshing rooms: {}", e); + // Check if this is a WebSocket error that needs reconnection + let error_str = e.to_string(); + if error_str.contains("WebSocket") || error_str.contains("not open") { + warn!( + "WebSocket error during room refresh, triggering reconnection" + ); + if let Err(e) = + message_tx.unbounded_send(SynchronizerMessage::ConnectionLost) + { + error!("Failed to send ConnectionLost: {}", e); + } + } + } else { + info!("Successfully refreshed all rooms"); + } + } SynchronizerMessage::Connect => { info!("Connecting to Freenet"); match connection_manager diff --git a/ui/src/components/app/freenet_api/room_synchronizer.rs b/ui/src/components/app/freenet_api/room_synchronizer.rs index e7afebdb..17b03109 100644 --- a/ui/src/components/app/freenet_api/room_synchronizer.rs +++ b/ui/src/components/app/freenet_api/room_synchronizer.rs @@ -461,6 +461,69 @@ impl RoomSynchronizer { }); } + /// Refresh all room states by sending GET requests. + /// This is used after PC suspension/wake to catch any updates that were missed + /// while the page was hidden or the machine was suspended. + pub async fn refresh_all_rooms(&self) -> Result<(), SynchronizerError> { + info!("Refreshing all rooms to catch missed updates"); + + // Check if WebAPI is available + let web_api_available = WEB_API.read().is_some(); + if !web_api_available { + warn!("WebAPI not available, skipping room refresh"); + return Err(SynchronizerError::ApiNotInitialized); + } + + // Collect all room owner keys that we're currently tracking + let room_owners: Vec = ROOMS.read().map.keys().copied().collect(); + + if room_owners.is_empty() { + info!("No rooms to refresh"); + return Ok(()); + } + + info!("Refreshing {} rooms", room_owners.len()); + + for owner_vk in room_owners { + let contract_key = owner_vk_to_contract_key(&owner_vk); + + // Send a GET request to fetch the current state + // This will trigger a response that merges any missed updates + let get_request = ContractRequest::Get { + key: *contract_key.id(), + return_contract_code: false, + subscribe: false, // Already subscribed, just need the state + }; + + let client_request = ClientRequest::ContractOp(get_request); + + if let Some(web_api) = WEB_API.write().as_mut() { + match web_api.send(client_request).await { + Ok(_) => { + info!( + "Sent refresh GET request for room {:?}", + MemberId::from(owner_vk) + ); + } + Err(e) => { + // Don't fail the entire refresh if one room fails + error!( + "Error sending refresh GET for room {:?}: {}", + MemberId::from(owner_vk), + e + ); + } + } + } else { + warn!("WebAPI became unavailable during refresh"); + return Err(SynchronizerError::ApiNotInitialized); + } + } + + info!("Finished sending refresh requests for all rooms"); + Ok(()) + } + /// Subscribe to a contract after a successful GET or PUT operation pub async fn subscribe_to_contract( &self, diff --git a/ui/src/example_data.rs b/ui/src/example_data.rs index 89294221..ca83e8a7 100644 --- a/ui/src/example_data.rs +++ b/ui/src/example_data.rs @@ -344,12 +344,16 @@ mod tests { ); // Verify room has at least basic configuration - assert!(!room_data - .room_state - .configuration - .configuration - .name - .is_empty()); + assert!( + room_data + .room_state + .configuration + .configuration + .display + .name + .declared_len() + > 0 + ); // Verify members list exists assert!(!room_data.room_state.members.members.is_empty()); From 63d2131b82197ac9302e55f694480d10083c9930 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Sun, 25 Jan 2026 10:35:45 -0600 Subject: [PATCH 2/2] fix: process GET responses for already-subscribed rooms The refresh_all_rooms() function sends GET requests to fetch current state after wake from suspension, but GET responses were only being processed for rooms in PENDING_INVITES. This fix adds handling for GET responses for already-subscribed rooms by: 1. Adding fallback lookup in ROOMS when owner_vk not found in SYNC_INFO 2. Processing GET responses for existing rooms by merging retrieved state into current room state This ensures updates missed during suspension are properly merged when the UI wakes up and refreshes room state. Co-Authored-By: Claude Opus 4.5 --- .../response_handler/get_response.rs | 65 ++++++++++++++++++- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/ui/src/components/app/freenet_api/response_handler/get_response.rs b/ui/src/components/app/freenet_api/response_handler/get_response.rs index 064a2a3e..314f37f0 100644 --- a/ui/src/components/app/freenet_api/response_handler/get_response.rs +++ b/ui/src/components/app/freenet_api/response_handler/get_response.rs @@ -28,7 +28,7 @@ pub async fn handle_get_response( // First try to find the owner_vk from SYNC_INFO let owner_vk = SYNC_INFO.read().get_owner_vk_for_instance_id(key.id()); - // If we couldn't find it in SYNC_INFO, try to find it in PENDING_INVITES by checking contract keys + // If we couldn't find it in SYNC_INFO, try fallback mechanisms let owner_vk = if owner_vk.is_none() { // This is a fallback mechanism in case SYNC_INFO wasn't properly set up warn!( @@ -36,6 +36,7 @@ pub async fn handle_get_response( key.id() ); + // First try PENDING_INVITES let pending_invites = PENDING_INVITES.read(); let mut found_owner_vk = None; @@ -50,15 +51,34 @@ pub async fn handle_get_response( break; } } + drop(pending_invites); + + // If not in pending invites, try ROOMS (for refresh after suspension) + if found_owner_vk.is_none() { + let rooms = ROOMS.read(); + for (owner_key, room_data) in rooms.map.iter() { + if room_data.contract_key.id() == key.id() { + info!( + "Found matching owner key in existing rooms: {:?}", + MemberId::from(*owner_key) + ); + found_owner_vk = Some(*owner_key); + break; + } + } + } found_owner_vk } else { owner_vk }; - // Now check if this is for a pending invitation + // Now check if this is for a pending invitation or an existing room needing refresh if let Some(owner_vk) = owner_vk { - if PENDING_INVITES.read().map.contains_key(&owner_vk) { + let is_pending_invite = PENDING_INVITES.read().map.contains_key(&owner_vk); + let is_existing_room = ROOMS.read().map.contains_key(&owner_vk); + + if is_pending_invite { info!("This is a subscription for a pending invitation, adding state"); let retrieved_state: ChatRoomStateV1 = from_cbor_slice::(&state); @@ -304,6 +324,45 @@ pub async fn handle_get_response( info!("Successfully triggered synchronization after joining room"); } } + } else if is_existing_room { + // This is a refresh GET for an already-subscribed room (e.g., after wake from suspension) + info!("Processing GET response for existing room (refresh after suspension)"); + let retrieved_state: ChatRoomStateV1 = from_cbor_slice::(&state); + + ROOMS.with_mut(|rooms| { + if let Some(room_data) = rooms.map.get_mut(&owner_vk) { + // Create parameters for merge + let params = ChatRoomParametersV1 { owner: owner_vk }; + + // Clone current state to avoid borrow issues during merge + let current_state = room_data.room_state.clone(); + + // Merge the retrieved state into the existing state + match room_data + .room_state + .merge(¤t_state, ¶ms, &retrieved_state) + { + Ok(_) => { + info!( + "Successfully merged refreshed state for room {:?}", + MemberId::from(owner_vk) + ); + } + Err(e) => { + error!( + "Failed to merge refreshed state for room {:?}: {}", + MemberId::from(owner_vk), + e + ); + } + } + } + }); + + // Update sync info to reflect we received fresh state + SYNC_INFO.with_mut(|sync_info| { + sync_info.update_sync_status(&owner_vk, RoomSyncStatus::Subscribed); + }); } }