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),