3 Commits

Author SHA1 Message Date
Siavash Sameni
cc23e829b2 feat(ui): handle PresenceList in lobby — show online users
The lobby now populates from PresenceList signal events:
- Relay broadcasts user list on register/deregister
- JS receives "presence_list" signal-event
- Updates lobbyUsers map (excluding self)
- Renders user rows with identicon, name, fingerprint

Users appear in the lobby as soon as they register their
signal channel — no need to join voice first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:13:45 +04:00
Siavash Sameni
18c204c1ff merge main: PresenceList signal for lobby 2026-04-14 18:13:15 +04:00
Siavash Sameni
1120c7b579 feat(signal): PresenceList broadcast for lobby user discovery
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 7m21s
Mirror to GitHub / mirror (push) Failing after 27s
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) <noreply@anthropic.com>
2026-04-14 18:12:47 +04:00
7 changed files with 78 additions and 1 deletions

View File

@@ -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
}
}

View File

@@ -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};

View File

@@ -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<String>,
}
/// 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<String>,
},
// ── 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<PresenceUser>,
},
// ── Quality upgrade negotiation (#28, #29) ──────────────────
/// Peer proposes upgrading to a higher quality profile.

View File

@@ -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;

View File

@@ -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<wzp_proto::PresenceUser> = 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)]

View File

@@ -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<serde_json::Value> = 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!({

View File

@@ -435,6 +435,20 @@ async function pollStatus() {
listen("signal-event", (event: any) => {
const data = event.payload;
switch (data.type) {
case "presence_list":
// Relay sent updated user list
lobbyUsers.clear();
for (const u of data.users || []) {
if (u.fingerprint === myFingerprint) continue; // don't show self
lobbyUsers.set(u.fingerprint, {
fingerprint: u.fingerprint,
alias: u.alias || null,
inVoice: false,
speaking: false,
});
}
renderLobbyUsers();
break;
case "ringing":
// We placed a call, it's ringing
break;