feat: relay-grouped participant rendering + relay_label in protocol
RoomParticipant now has optional relay_label field. Desktop client groups participants by relay: "This Relay" (green dot) for local, peer label (blue dot) for federated. Shows all relays in the chain including intermediate ones. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -670,6 +670,10 @@ pub struct RoomParticipant {
|
||||
pub fingerprint: String,
|
||||
/// Optional display name set by the client.
|
||||
pub alias: Option<String>,
|
||||
/// Relay label — identifies which relay this participant is connected to.
|
||||
/// None for local participants, Some("Relay B") for federated.
|
||||
#[serde(default)]
|
||||
pub relay_label: Option<String>,
|
||||
}
|
||||
|
||||
/// Reasons for ending a call.
|
||||
|
||||
@@ -45,6 +45,7 @@ unsafe impl Sync for SyncWrapper {}
|
||||
pub struct ParticipantInfo {
|
||||
pub fingerprint: String,
|
||||
pub alias: Option<String>,
|
||||
pub relay_label: Option<String>,
|
||||
}
|
||||
|
||||
pub struct EngineStatus {
|
||||
@@ -346,6 +347,7 @@ impl CallEngine {
|
||||
.map(|p| ParticipantInfo {
|
||||
fingerprint: p.fingerprint,
|
||||
alias: p.alias,
|
||||
relay_label: p.relay_label,
|
||||
})
|
||||
.collect();
|
||||
let count = unique.len();
|
||||
@@ -395,6 +397,7 @@ impl CallEngine {
|
||||
.map(|p| ParticipantInfo {
|
||||
fingerprint: p.fingerprint.clone(),
|
||||
alias: p.alias.clone(),
|
||||
relay_label: p.relay_label.clone(),
|
||||
})
|
||||
.collect()
|
||||
}; // lock dropped here
|
||||
|
||||
@@ -18,6 +18,7 @@ struct CallEvent {
|
||||
struct Participant {
|
||||
fingerprint: String,
|
||||
alias: Option<String>,
|
||||
relay_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
@@ -195,6 +196,7 @@ async fn get_status(state: tauri::State<'_, Arc<AppState>>) -> Result<CallStatus
|
||||
.map(|p| Participant {
|
||||
fingerprint: p.fingerprint,
|
||||
alias: p.alias,
|
||||
relay_label: p.relay_label,
|
||||
})
|
||||
.collect(),
|
||||
encode_fps: status.frames_sent,
|
||||
|
||||
@@ -540,32 +540,49 @@ async function pollStatus() {
|
||||
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
|
||||
levelBar.style.width = `${pct}%`;
|
||||
|
||||
// Participants with identicons
|
||||
// Participants grouped by relay
|
||||
if (st.participants.length === 0) {
|
||||
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
|
||||
} else {
|
||||
participantsDiv.innerHTML = "";
|
||||
st.participants.forEach((p) => {
|
||||
const name = p.alias || "Anonymous";
|
||||
const fp = p.fingerprint || "";
|
||||
const isMe = fp && myFingerprint.includes(fp);
|
||||
// Group by relay_label (null = this relay)
|
||||
const groups: Record<string, typeof st.participants> = {};
|
||||
st.participants.forEach((p: any) => {
|
||||
const relay = p.relay_label || "This Relay";
|
||||
if (!groups[relay]) groups[relay] = [];
|
||||
groups[relay].push(p);
|
||||
});
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "participant";
|
||||
Object.entries(groups).forEach(([relay, members]) => {
|
||||
// Relay header
|
||||
const header = document.createElement("div");
|
||||
header.className = "relay-group-header";
|
||||
const isLocal = relay === "This Relay";
|
||||
header.innerHTML = `<span class="relay-dot-small ${isLocal ? "green" : "blue"}"></span> ${escapeHtml(relay)}`;
|
||||
participantsDiv.appendChild(header);
|
||||
|
||||
// Identicon avatar
|
||||
const icon = createIdenticonEl(fp || name, 36, true);
|
||||
if (isMe) icon.style.outline = "2px solid var(--accent)";
|
||||
row.appendChild(icon);
|
||||
// Participants under this relay
|
||||
(members as any[]).forEach((p) => {
|
||||
const name = p.alias || "Anonymous";
|
||||
const fp = p.fingerprint || "";
|
||||
const isMe = fp && myFingerprint.includes(fp);
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "info";
|
||||
info.innerHTML = `
|
||||
<div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
|
||||
<div class="fp">${escapeHtml(fp ? fp.substring(0, 16) : "")}</div>
|
||||
`;
|
||||
row.appendChild(info);
|
||||
participantsDiv.appendChild(row);
|
||||
const row = document.createElement("div");
|
||||
row.className = "participant";
|
||||
|
||||
const icon = createIdenticonEl(fp || name, 36, true);
|
||||
if (isMe) icon.style.outline = "2px solid var(--accent)";
|
||||
row.appendChild(icon);
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "info";
|
||||
info.innerHTML = `
|
||||
<div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
|
||||
<div class="fp">${escapeHtml(fp ? fp.substring(0, 16) : "")}</div>
|
||||
`;
|
||||
row.appendChild(info);
|
||||
participantsDiv.appendChild(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -441,6 +441,35 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── Relay group headers ── */
|
||||
.relay-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-dim);
|
||||
padding: 6px 0 2px;
|
||||
border-top: 1px solid #ffffff08;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.relay-group-header:first-child {
|
||||
border-top: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.relay-dot-small {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.relay-dot-small.green { background: var(--green); }
|
||||
.relay-dot-small.blue { background: #60a5fa; }
|
||||
|
||||
/* ── Controls ── */
|
||||
.controls {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user