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
+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);
}