Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
306d3c0
feat: add video/call page state, web-sys features, and icons
intendednull Mar 26, 2026
d44fd2e
refactor: VoiceManager connection reuse, perfect negotiation, video t…
intendednull Mar 26, 2026
545cd97
feat: call page with participant tiles, camera, screen share, and con…
intendednull Mar 26, 2026
e4e1e1f
feat: speaking detection with AudioContext analysers
intendednull Mar 26, 2026
b05d8da
fix: resolve clippy warnings in web crate
intendednull Mar 26, 2026
fa25fcd
Fix local video not displaying on own tile in call page
intendednull Mar 26, 2026
7e037ba
fix: screen share black screen — deref SendWrapper and defer play()
intendednull Mar 26, 2026
247ce03
fix: 7 call page bugs — onended cleanup, duplicate offers, audio leak…
intendednull Mar 26, 2026
39f8eeb
fix: remote peer not receiving screen share video
intendednull Mar 26, 2026
b12ee89
fix: tabbed settings panel, voice reconnect participants, cleanup
intendednull Mar 26, 2026
bf4b133
fix: settings tabs CSS — match .tab-btn class, add glow + polish
intendednull Mar 26, 2026
1831421
test: add cross-browser E2E tests (mobile Chrome ↔ desktop Firefox)
intendednull Mar 26, 2026
8645c26
feat: auto-reconnect with exponential backoff on WASM
intendednull Mar 26, 2026
14b9fc0
feat: auto-reconnect with exponential backoff on WASM
intendednull Mar 26, 2026
1dc9c33
fix: 4 bugs — reconnect re-subscribe, participant count, voice switch…
intendednull Mar 26, 2026
8f3c689
fix: participant count header still double-counted local user
intendednull Mar 26, 2026
44cad75
Merge remote-tracking branch 'origin/main' into feat/video-screen-sha…
intendednull Mar 26, 2026
682222e
fix: 6 deep-review issues — close_all cleanup, detector recreation, t…
intendednull Mar 26, 2026
5ce4fe3
chore: cargo fmt formatting fixes
intendednull Mar 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2486,7 +2486,10 @@ impl ClientEventLoop {
}
network::NetworkEvent::Listening(addr) => {
let mut shared = self.shared.borrow_mut();
// On receiving a listening event, do initial subscriptions.
// Reset subscription flag on reconnect so on_connected re-runs.
if addr == "reconnecting" {
shared.connected_subscribed = false;
}
if !shared.connected_subscribed {
on_connected(&shared.state, &self.cmd_tx);
shared.connected_subscribed = true;
Expand Down
209 changes: 120 additions & 89 deletions crates/client/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,110 +342,141 @@ async fn run_network_wasm(
use futures::StreamExt;
use willow_network::{NetworkEvent as NetEvt, NetworkNode};

let (node, mut events) = NetworkNode::start(identity, config).await?;
let mut file_mgr = crate::files::FileManager::new();
let local_peer_id = node.peer_id().to_string();
let mut backoff_ms: u32 = 1_000;
const MAX_BACKOFF_MS: u32 = 30_000;

loop {
futures::select! {
event = events.next() => {
let Some(event) = event else { break };
match event {
NetEvt::Message { topic, data, source } => {
// Try parsing as a profile broadcast.
if let Ok((profile, willow_transport::MessageType::Identity)) =
willow_transport::unpack_envelope::<willow_identity::UserProfile>(&data)
{
let _ = event_tx.unbounded_send(NetworkEvent::ProfileReceived {
peer_id: profile.peer_id.to_string(),
display_name: profile.display_name,
});
}
// Try wire format.
else if let Some((wire_msg, signer)) =
crate::ops::unpack_wire(&data)
{
let from = signer.to_string();
match wire_msg {
crate::ops::WireMessage::Event(event) => {
let _ = event_tx.unbounded_send(NetworkEvent::EventReceived {
event,
from,
});
}
crate::ops::WireMessage::SyncRequest { state_hash, topic } => {
let _ = event_tx.unbounded_send(NetworkEvent::SyncRequested {
state_hash,
from,
topic,
});
}
crate::ops::WireMessage::SyncBatch { events } => {
let _ = event_tx.unbounded_send(NetworkEvent::SyncBatchReceived {
events,
from,
});
}
crate::ops::WireMessage::TypingIndicator { channel } => {
let _ = event_tx.unbounded_send(NetworkEvent::TypingReceived {
peer_id: from,
channel,
});
}
crate::ops::WireMessage::VoiceJoin { channel_id, peer_id } => {
let _ = event_tx.unbounded_send(NetworkEvent::VoiceJoinReceived {
channel_id,
peer_id,
});
}
crate::ops::WireMessage::VoiceLeave { channel_id, peer_id } => {
let _ = event_tx.unbounded_send(NetworkEvent::VoiceLeaveReceived {
channel_id,
peer_id,
});
}
crate::ops::WireMessage::VoiceSignal { channel_id, target_peer, signal } => {
if target_peer == local_peer_id {
let _ = event_tx.unbounded_send(NetworkEvent::VoiceSignalReceived {
tracing::info!("connecting to network...");

let node_result = NetworkNode::start(identity.clone(), config.clone()).await;
let (node, mut events) = match node_result {
Ok(pair) => {
// Successful connection — reset backoff.
backoff_ms = 1_000;
pair
}
Err(e) => {
tracing::warn!("failed to start network node: {e}");
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
continue;
}
};

let mut file_mgr = crate::files::FileManager::new();
let local_peer_id = node.peer_id().to_string();

// Run the event loop until the connection drops.
loop {
futures::select! {
event = events.next() => {
let Some(event) = event else { break };
match event {
NetEvt::Message { topic, data, source } => {
// Try parsing as a profile broadcast.
if let Ok((profile, willow_transport::MessageType::Identity)) =
willow_transport::unpack_envelope::<willow_identity::UserProfile>(&data)
{
let _ = event_tx.unbounded_send(NetworkEvent::ProfileReceived {
peer_id: profile.peer_id.to_string(),
display_name: profile.display_name,
});
}
// Try wire format.
else if let Some((wire_msg, signer)) =
crate::ops::unpack_wire(&data)
{
let from = signer.to_string();
match wire_msg {
crate::ops::WireMessage::Event(event) => {
let _ = event_tx.unbounded_send(NetworkEvent::EventReceived {
event,
from,
});
}
crate::ops::WireMessage::SyncRequest { state_hash, topic } => {
let _ = event_tx.unbounded_send(NetworkEvent::SyncRequested {
state_hash,
from,
topic,
});
}
crate::ops::WireMessage::SyncBatch { events } => {
let _ = event_tx.unbounded_send(NetworkEvent::SyncBatchReceived {
events,
from,
});
}
crate::ops::WireMessage::TypingIndicator { channel } => {
let _ = event_tx.unbounded_send(NetworkEvent::TypingReceived {
peer_id: from,
channel,
});
}
crate::ops::WireMessage::VoiceJoin { channel_id, peer_id } => {
let _ = event_tx.unbounded_send(NetworkEvent::VoiceJoinReceived {
channel_id,
from_peer: from,
signal,
peer_id,
});
}
crate::ops::WireMessage::VoiceLeave { channel_id, peer_id } => {
let _ = event_tx.unbounded_send(NetworkEvent::VoiceLeaveReceived {
channel_id,
peer_id,
});
}
crate::ops::WireMessage::VoiceSignal { channel_id, target_peer, signal } => {
if target_peer == local_peer_id {
let _ = event_tx.unbounded_send(NetworkEvent::VoiceSignalReceived {
channel_id,
from_peer: from,
signal,
});
}
}
}
} else {
let _ = event_tx.unbounded_send(NetworkEvent::MessageReceived {
topic,
data,
source: source.map(|p| p.to_string()),
});
}
} else {
let _ = event_tx.unbounded_send(NetworkEvent::MessageReceived {
topic,
data,
source: source.map(|p| p.to_string()),
});
}
NetEvt::PeerConnected(peer) => {
// Reset backoff on successful peer connection.
backoff_ms = 1_000;
let _ = event_tx.unbounded_send(NetworkEvent::PeerConnected(peer.to_string()));
}
NetEvt::PeerDisconnected(peer) => {
let _ = event_tx.unbounded_send(NetworkEvent::PeerDisconnected(peer.to_string()));
}
NetEvt::Listening(addr) => {
let _ = event_tx.unbounded_send(NetworkEvent::Listening(addr.to_string()));
}
_ => {}
}
NetEvt::PeerConnected(peer) => {
let _ = event_tx.unbounded_send(NetworkEvent::PeerConnected(peer.to_string()));
}
NetEvt::PeerDisconnected(peer) => {
let _ = event_tx.unbounded_send(NetworkEvent::PeerDisconnected(peer.to_string()));
}
NetEvt::Listening(addr) => {
let _ = event_tx.unbounded_send(NetworkEvent::Listening(addr.to_string()));
}
_ => {}
}
}

cmd = cmd_rx.next() => {
if let Some(cmd) = cmd {
handle_network_command(&cmd, &node, &mut file_mgr)?;
cmd = cmd_rx.next() => {
if let Some(cmd) = cmd {
let _ = handle_network_command(&cmd, &node, &mut file_mgr);
}
}
}

complete => break,
complete => break,
}
}
}

Ok(())
// Connection lost — notify UI and wait before retrying.
tracing::warn!(
backoff_ms,
"network connection lost, reconnecting in {backoff_ms}ms"
);
let _ = event_tx.unbounded_send(NetworkEvent::Listening("reconnecting".to_string()));
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
}
}

// ───── Shared command handler ───────────────────────────────────────────────
Expand Down
4 changes: 4 additions & 0 deletions crates/web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ web-sys = { version = "0.3", features = [
"RtcRtpSender", "MediaStream", "MediaStreamTrack",
"MediaStreamConstraints", "MediaDevices", "HtmlMediaElement",
"ScrollIntoViewOptions", "ScrollBehavior", "ScrollLogicalPosition",
"DisplayMediaStreamConstraints",
"AudioContext", "BaseAudioContext", "AudioNode",
"AnalyserNode", "MediaStreamAudioSourceNode",
"RtcSignalingState", "RtcRtpTransceiver",
] }
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
Expand Down
Loading