feat(w7): cosmetic room labels carried in the ticket (P5)

Rooms can now be named. A "Room name (optional)" field on the home Create card
mints a ticket carrying the label; PeerSpeakTicket gains a #[serde(default)]
`name` field (backward/forward compatible — serde ignores unknown fields and
defaults missing ones, so old/new builds still interoperate, just without
labels). restamp preserves the label so member-issued doors keep it; new
label_of helper reads it. Every member (creator or joiner) sets current_room.name
from the ticket, so presence reports a consistent "in <name>" to friends, and the
room-screen header shows the label under the wordmark. Labels are sanitized via
sanitize_name on both mint and display (untrusted peer-supplied ticket).

CoreCommand::Join gains room_name (used only when creating). +2 ticket tests
(label round-trip through restamp/label_of, pre-label backward-compat). clippy
--all-targets clean, 257 lib tests green.

Pure seam unit-tested + home field screenshot-verified; the in-room header label
and friend-side "in HangOut" presence display need a live/2-machine confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 14:32:11 -04:00
co-authored by Claude Opus 4.8
parent be42941b97
commit 4227ecc61d
6 changed files with 127 additions and 24 deletions
+43 -9
View File
@@ -110,6 +110,7 @@ fn clamp_chat_drawer_width(width: f32, window_w: f32) -> f32 {
pub enum AppMessage {
NicknameChanged(String),
TicketInputChanged(String),
RoomNameChanged(String),
JoinPressed,
CreatePressed,
LeavePressed,
@@ -216,6 +217,9 @@ fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
pub struct AppState {
name: String,
ticket_input: String,
/// The optional cosmetic room label typed on the home "Create" card (W7).
/// Carried in the minted ticket so joiners inherit it; empty = unnamed room.
room_name_input: String,
status_message: String,
self_id: String,
ticket: String,
@@ -355,6 +359,7 @@ impl Default for AppState {
// Pre-fill the nickname with the last one used (or "Peer" by default).
name: config.username.clone(),
ticket_input: "".to_string(),
room_name_input: "".to_string(),
status_message: "Ready to connect".to_string(),
self_id: "".to_string(),
ticket: "".to_string(),
@@ -521,6 +526,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::TicketInputChanged(val) => {
state.ticket_input = val;
}
AppMessage::RoomNameChanged(val) => {
state.room_name_input = val;
}
AppMessage::JoinPressed => {
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
@@ -534,6 +542,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
let _ = state.controller.send(CoreCommand::Join {
name: state.name.clone(),
ticket: state.ticket_input.clone(),
room_name: String::new(), // joining: the label comes from the ticket
input_device,
output_device,
echo_cancellation: state.config.echo_cancellation_enabled,
@@ -553,6 +562,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
let _ = state.controller.send(CoreCommand::Join {
name: state.name.clone(),
ticket: "create".to_string(),
room_name: state.room_name_input.clone(),
input_device,
output_device,
echo_cancellation: state.config.echo_cancellation_enabled,
@@ -848,6 +858,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
let _ = state.controller.send(CoreCommand::Join {
name: state.name.clone(),
ticket,
room_name: String::new(), // joining: the label comes from the ticket
input_device,
output_device,
echo_cancellation: state.config.echo_cancellation_enabled,
@@ -1266,11 +1277,21 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
.padding(10)
];
let create_btn = button(btn_content(IconKind::Create, "Create New Room", color_crust))
.on_press(AppMessage::CreatePressed)
.style(b_style(color_blue, color_lavender, color_crust, 8.0))
.padding(12)
.width(iced::Length::Fill);
// Optional cosmetic room label (W7) above the Create button: it rides in the
// minted ticket so everyone who joins inherits "in <name>". Enter also creates.
let create_group = column![
text_input("Room name (optional)", &state.room_name_input)
.on_input(AppMessage::RoomNameChanged)
.on_submit(AppMessage::CreatePressed)
.style(t_style)
.padding(10),
vertical_space(8.0),
button(btn_content(IconKind::Create, "Create New Room", color_crust))
.on_press(AppMessage::CreatePressed)
.style(b_style(color_blue, color_lavender, color_crust, 8.0))
.padding(12)
.width(iced::Length::Fill),
];
let join_group = column![
text("Join Existing Room").size(14).color(color_subtext),
@@ -1296,7 +1317,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
vertical_space(20.0),
nickname_input,
vertical_space(16.0),
create_btn,
create_group,
vertical_space(16.0),
text("— OR —").size(12).color(color_surface).align_x(iced::alignment::Horizontal::Center),
vertical_space(16.0),
@@ -2154,10 +2175,23 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
// --- ROOM SCREEN ---
let participant_count = state.peers.len() + 1; // peers + you
let call_secs = state.call_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
// The room's cosmetic label (W7) rides in our share ticket; show it under
// the wordmark when the room was named. Sanitized since a joined ticket is
// peer-supplied.
let room_label = crate::sanitize::sanitize_name(
&crate::network::PeerSpeakTicket::label_of(&state.ticket),
);
let title: Element<'_, AppMessage> = if room_label.is_empty() {
text("PEERSPEAK").size(20).color(color_blue).into()
} else {
column![
text("PEERSPEAK").size(20).color(color_blue),
text(room_label).size(13).color(color_subtext),
]
.into()
};
let header = row![
text("PEERSPEAK")
.size(20)
.color(color_blue),
title,
horizontal_space(),
row![
icon(IconKind::People, 15.0, color_subtext),
+1
View File
@@ -53,6 +53,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let ticket = peerspeak::network::PeerSpeakTicket {
host_addr: endpoint_a.addr(),
topic_id,
name: String::new(),
};
let ticket_str = ticket.to_string();
println!("Ticket generated: {}", ticket_str);
+4 -1
View File
@@ -6,7 +6,10 @@ use iroh::{EndpointAddr, EndpointId};
#[derive(Debug, Clone)]
pub enum CoreCommand {
Join { name: String, ticket: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
/// Join a room. `ticket` is "create" (or empty) to mint a fresh room, else a
/// share ticket to join. `room_name` is the creator's chosen cosmetic label
/// for a NEW room; it's ignored when joining (the label rides in the ticket).
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
Leave,
ToggleMute,
/// Change our avatar (W4) and re-announce it to the room over presence.
+12 -5
View File
@@ -760,7 +760,7 @@ async fn run_core_loop(
}
};
match cmd {
CoreCommand::Join { name, ticket, input_device, output_device, echo_cancellation, avatar } => {
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
current_name = name.clone();
current_avatar = avatar;
@@ -804,7 +804,10 @@ async fn run_core_loop(
let topic_id: [u8; 32] = rand::random();
let host_addr = endpoint.addr();
crate::log_msg(&format!("Creating room. host_addr={:?}, topic_id={:?}", host_addr, topic_id));
let ticket = PeerSpeakTicket { host_addr, topic_id };
// The creator's chosen cosmetic label rides in the ticket so
// every joiner inherits it; sanitize it before it leaves here.
let label = crate::sanitize::sanitize_name(&room_name);
let ticket = PeerSpeakTicket { host_addr, topic_id, name: label };
ticket.to_string()
} else {
let ticket_str = ticket.trim().to_string();
@@ -1376,10 +1379,14 @@ async fn run_core_loop(
// parsed (a malformed join, which fails anyway).
let share_ticket = PeerSpeakTicket::restamp(&ticket_str, endpoint.addr());
// Advertise this gathering to friends who ping us (W7 B2): our own
// restamped member ticket → a one-click Join. Name is empty until
// cosmetic room labels exist (P5/recents).
// restamped member ticket → a one-click Join. The cosmetic label
// rides in the ticket (set by the creator), so every member —
// creator or joiner — reports the same room name. Re-sanitize the
// parsed label since the ticket is peer-supplied (untrusted).
let room_label =
crate::sanitize::sanitize_name(&PeerSpeakTicket::label_of(&share_ticket));
*current_room.lock().unwrap() = Some(crate::presence::RoomPresence {
name: String::new(),
name: room_label,
ticket: share_ticket.clone(),
});
let _ = ui_tx.send(UiEvent::RoomJoined { ticket: share_ticket, self_id }).await;
+61 -8
View File
@@ -82,20 +82,38 @@ pub enum ConnEvent {
pub struct PeerSpeakTicket {
pub host_addr: iroh::EndpointAddr,
pub topic_id: [u8; 32],
/// Cosmetic room label (W7), chosen by the creator and carried in the ticket
/// so everyone who joins via it inherits the same label and reports "in
/// HangOut" in presence. Untrusted on receipt (it rides a peer-shared ticket)
/// — sanitize before display. `#[serde(default)]` keeps older, pre-label
/// tickets parseable (they decode to an empty label).
#[serde(default)]
pub name: String,
}
impl PeerSpeakTicket {
/// Member-issued ticket (W7 P3): re-stamp an existing ticket string with our
/// OWN address while keeping its room `topic_id`, so any member can hand out a
/// working door that bootstraps off themselves — the mechanism that lets a
/// room outlive its creator. A no-op (returns the input unchanged) if the
/// string can't be parsed. Re-stamping with the same address is idempotent.
/// OWN address while keeping its room `topic_id` AND its cosmetic `name`, so
/// any member can hand out a working door that bootstraps off themselves — the
/// mechanism that lets a room outlive its creator. A no-op (returns the input
/// unchanged) if the string can't be parsed. Re-stamping with the same address
/// is idempotent.
pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String {
match ticket_str.parse::<PeerSpeakTicket>() {
Ok(t) => PeerSpeakTicket { host_addr: my_addr, topic_id: t.topic_id }.to_string(),
Ok(t) => {
PeerSpeakTicket { host_addr: my_addr, topic_id: t.topic_id, name: t.name }
.to_string()
}
Err(_) => ticket_str.to_string(),
}
}
/// The cosmetic room label embedded in a ticket string, or `""` if the ticket
/// can't be parsed or carries no label. Pure; used to label the gathering both
/// in the room UI and in the presence we report to friends.
pub fn label_of(ticket_str: &str) -> String {
ticket_str.parse::<PeerSpeakTicket>().map(|t| t.name).unwrap_or_default()
}
}
impl std::fmt::Display for PeerSpeakTicket {
@@ -210,11 +228,41 @@ mod tests {
let original_ticket = PeerSpeakTicket {
host_addr: state.addr.clone(),
topic_id,
name: "HangOut".to_string(),
};
let ticket_str = original_ticket.to_string();
let parsed_ticket = ticket_str.parse::<PeerSpeakTicket>().unwrap();
assert_eq!(parsed_ticket.host_addr.id, original_ticket.host_addr.id);
assert_eq!(parsed_ticket.topic_id, original_ticket.topic_id);
assert_eq!(parsed_ticket.name, "HangOut");
}
#[test]
fn test_ticket_label_helpers_and_backcompat() {
let host = SecretKey::generate().public();
let topic_id = [3u8; 32];
// A labelled ticket: restamp keeps the label, label_of reads it.
let labelled =
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
.to_string();
assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut");
let member = SecretKey::generate().public();
let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member));
assert_eq!(PeerSpeakTicket::label_of(&restamped), "HangOut");
// An unparseable ticket has no label rather than panicking.
assert_eq!(PeerSpeakTicket::label_of("not-a-ticket"), "");
// Backward-compat: a pre-label ticket JSON (no `name` key) still parses,
// defaulting the label to "".
let legacy_json = serde_json::json!({
"host_addr": serde_json::to_value(EndpointAddr::from(host)).unwrap(),
"topic_id": topic_id.to_vec(),
});
let legacy_str = base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
serde_json::to_vec(&legacy_json).unwrap(),
);
let parsed = legacy_str.parse::<PeerSpeakTicket>().unwrap();
assert_eq!(parsed.name, "");
}
#[test]
@@ -243,21 +291,26 @@ mod tests {
let host = SecretKey::generate().public();
let member = SecretKey::generate().public();
let topic_id = [42u8; 32];
let original = PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id }.to_string();
let original =
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
.to_string();
let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member));
let restamped = restamped_str.parse::<PeerSpeakTicket>().unwrap();
// Same room, but the door now points at the member, not the host.
// Same room (and label), but the door now points at the member, not the host.
assert_eq!(restamped.topic_id, topic_id);
assert_eq!(restamped.host_addr.id, member);
assert_ne!(restamped.host_addr.id, host);
assert_eq!(restamped.name, "HangOut");
}
#[test]
fn test_restamp_is_idempotent_for_same_addr() {
let me = SecretKey::generate().public();
let topic_id = [7u8; 32];
let mine = PeerSpeakTicket { host_addr: EndpointAddr::from(me), topic_id }.to_string();
let mine =
PeerSpeakTicket { host_addr: EndpointAddr::from(me), topic_id, name: String::new() }
.to_string();
// Re-stamping my own ticket with my own addr changes nothing.
assert_eq!(PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)), mine);
}
+6 -1
View File
@@ -145,7 +145,12 @@ mod tests {
}
fn valid_ticket(for_id: EndpointId) -> String {
PeerSpeakTicket { host_addr: EndpointAddr::from(for_id), topic_id: [9u8; 32] }.to_string()
PeerSpeakTicket {
host_addr: EndpointAddr::from(for_id),
topic_id: [9u8; 32],
name: String::new(),
}
.to_string()
}
#[test]