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:
+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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user