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:
+43
-9
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user