From 1120c7b579cd68bbad9f89c37ad424cf18d465ca Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Tue, 14 Apr 2026 18:12:47 +0400 Subject: [PATCH] feat(signal): PresenceList broadcast for lobby user discovery New signal infrastructure for the lobby-first UI: - PresenceUser struct: { fingerprint, alias } - SignalMessage::PresenceList: relay broadcasts full user list to all signal clients on every register/deregister - SignalHub::presence_list(): builds the list from connected clients - SignalHub::broadcast(): sends to ALL signal clients - Relay calls broadcast on register + unregister - Desktop emits "presence_list" signal-event to JS frontend This gives clients real-time visibility of who's online via the signal channel, without needing to join a voice room first. 603 tests pass, 0 regressions. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/wzp-client/src/featherchat.rs | 1 + crates/wzp-proto/src/lib.rs | 2 +- crates/wzp-proto/src/packet.rs | 18 ++++++++++++++++++ crates/wzp-relay/src/main.rs | 10 ++++++++++ crates/wzp-relay/src/signal_hub.rs | 20 ++++++++++++++++++++ desktop/src-tauri/src/lib.rs | 14 ++++++++++++++ 6 files changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/wzp-client/src/featherchat.rs b/crates/wzp-client/src/featherchat.rs index 8297c0e..35a3251 100644 --- a/crates/wzp-client/src/featherchat.rs +++ b/crates/wzp-client/src/featherchat.rs @@ -138,6 +138,7 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType { | SignalMessage::UpgradeResponse { .. } | SignalMessage::UpgradeConfirm { .. } | SignalMessage::QualityCapability { .. } => CallSignalType::Offer, // quality negotiation + SignalMessage::PresenceList { .. } => CallSignalType::Offer, // lobby presence SignalMessage::QualityDirective { .. } => CallSignalType::Offer, // relay-initiated } } diff --git a/crates/wzp-proto/src/lib.rs b/crates/wzp-proto/src/lib.rs index ab7bd7a..13e9479 100644 --- a/crates/wzp-proto/src/lib.rs +++ b/crates/wzp-proto/src/lib.rs @@ -27,7 +27,7 @@ pub use codec_id::{CodecId, QualityProfile}; pub use error::*; pub use packet::{ CallAcceptMode, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, - QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, + PresenceUser, QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI, }; pub use bandwidth::{BandwidthEstimator, CongestionState}; diff --git a/crates/wzp-proto/src/packet.rs b/crates/wzp-proto/src/packet.rs index 4f254e0..8cd0d89 100644 --- a/crates/wzp-proto/src/packet.rs +++ b/crates/wzp-proto/src/packet.rs @@ -156,6 +156,14 @@ impl MediaHeader { } } +/// A user visible in the signal presence list. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PresenceUser { + pub fingerprint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, +} + /// Quality report appended to a media packet when Q flag is set (4 bytes). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QualityReport { @@ -1020,6 +1028,16 @@ pub enum SignalMessage { reason: Option, }, + // ── Signal presence ─────────────────────────────────────────── + + /// Relay broadcasts the list of currently registered signal + /// users to all connected clients. Sent on every register/ + /// deregister so clients can maintain a live lobby user list. + PresenceList { + /// List of online users. Each entry is { fingerprint, alias }. + users: Vec, + }, + // ── Quality upgrade negotiation (#28, #29) ────────────────── /// Peer proposes upgrading to a higher quality profile. diff --git a/crates/wzp-relay/src/main.rs b/crates/wzp-relay/src/main.rs index be54506..11a41d3 100644 --- a/crates/wzp-relay/src/main.rs +++ b/crates/wzp-relay/src/main.rs @@ -1016,6 +1016,13 @@ async fn main() -> anyhow::Result<()> { info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered"); + // Broadcast updated presence to all signal clients + { + let hub = signal_hub.lock().await; + let presence = hub.presence_list(); + hub.broadcast(&presence).await; + } + // Signal recv loop loop { match transport.recv_signal().await { @@ -1560,6 +1567,9 @@ async fn main() -> anyhow::Result<()> { { let mut hub = signal_hub.lock().await; hub.unregister(&client_fp); + // Broadcast updated presence to remaining clients + let presence_msg = hub.presence_list(); + hub.broadcast(&presence_msg).await; } { let mut reg = presence.lock().await; diff --git a/crates/wzp-relay/src/signal_hub.rs b/crates/wzp-relay/src/signal_hub.rs index 2d497a6..08d7b6f 100644 --- a/crates/wzp-relay/src/signal_hub.rs +++ b/crates/wzp-relay/src/signal_hub.rs @@ -86,6 +86,26 @@ impl SignalHub { pub fn alias(&self, fp: &str) -> Option<&str> { self.clients.get(fp).and_then(|c| c.alias.as_deref()) } + + /// Build a PresenceList message with all online users. + pub fn presence_list(&self) -> SignalMessage { + let users: Vec = self + .clients + .values() + .map(|c| wzp_proto::PresenceUser { + fingerprint: c.fingerprint.clone(), + alias: c.alias.clone(), + }) + .collect(); + SignalMessage::PresenceList { users } + } + + /// Broadcast a message to ALL connected signal clients. + pub async fn broadcast(&self, msg: &SignalMessage) { + for client in self.clients.values() { + let _ = client.transport.send_signal(msg).await; + } + } } #[cfg(test)] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6ec0c73..423d714 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1451,6 +1451,20 @@ fn do_register_signal( }); } } + Ok(Some(SignalMessage::PresenceList { users })) => { + tracing::info!(count = users.len(), "signal: PresenceList received"); + // Emit to JS frontend for lobby user list + let user_list: Vec = users.iter().map(|u| { + serde_json::json!({ + "fingerprint": u.fingerprint, + "alias": u.alias, + }) + }).collect(); + let _ = app_clone.emit("signal-event", serde_json::json!({ + "type": "presence_list", + "users": user_list, + })); + } Ok(Some(SignalMessage::UpgradeProposal { call_id, proposal_id, proposed_profile, local_loss_pct, local_rtt_ms })) => { tracing::info!(%call_id, %proposal_id, ?proposed_profile, "signal: UpgradeProposal from peer"); emit_call_debug(&app_clone, "recv:UpgradeProposal", serde_json::json!({