From 4227ecc61d49e263b10b81062f081b0a41c9e285 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 16 Jun 2026 14:32:11 -0400 Subject: [PATCH] feat(w7): cosmetic room labels carried in the ticket (P5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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 --- src/app/mod.rs | 52 +++++++++++++++++++++++++++------ src/bin/test_net.rs | 1 + src/core/messages.rs | 5 +++- src/core/mod.rs | 17 +++++++---- src/network/mod.rs | 69 +++++++++++++++++++++++++++++++++++++++----- src/presence.rs | 7 ++++- 6 files changed, 127 insertions(+), 24 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 0ed6554..7d231bd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 { 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::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 { 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 { 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 { 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 ". 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), diff --git a/src/bin/test_net.rs b/src/bin/test_net.rs index 9298541..3627c46 100644 --- a/src/bin/test_net.rs +++ b/src/bin/test_net.rs @@ -53,6 +53,7 @@ async fn main() -> Result<(), Box> { 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); diff --git a/src/core/messages.rs b/src/core/messages.rs index ed01194..434432a 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -6,7 +6,10 @@ use iroh::{EndpointAddr, EndpointId}; #[derive(Debug, Clone)] pub enum CoreCommand { - Join { name: String, ticket: String, input_device: Option, output_device: Option, 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, output_device: Option, echo_cancellation: bool, avatar: crate::avatar::Avatar }, Leave, ToggleMute, /// Change our avatar (W4) and re-announce it to the room over presence. diff --git a/src/core/mod.rs b/src/core/mod.rs index af880a2..b1901c5 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -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; diff --git a/src/network/mod.rs b/src/network/mod.rs index 0178700..83cf03e 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -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::() { - 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::().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::().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::().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::().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); } diff --git a/src/presence.rs b/src/presence.rs index 80623d2..28b8251 100644 --- a/src/presence.rs +++ b/src/presence.rs @@ -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]