From f161534a4b1dd85a8eacf736e1a9630884edd33c Mon Sep 17 00:00:00 2001 From: Mollusk Date: Mon, 15 Jun 2026 04:47:56 -0400 Subject: [PATCH] feat(friends): friends-list UI in Settings (W7 P5) Wire the P2 friends store into the UI. New 'Friends' section in Settings: loads the store at startup, lists each saved friend as a live-rename text field + short id + Remove button, and an add row (node-id + optional name + Add) that validates the id parses as an EndpointId, rejects duplicates, and falls back to a short-id name when none is given. Every change persists via friends::save. Empty state prompts adding by node id. Solo-verifiable (copy your own ID from the Identity section to add a row). Friends currently live in Settings; they will likely move to a prominent home-screen panel once P4 presence gives them live status. 256 lib tests green, clippy clean, release builds. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 133 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index 9af919a..38a750b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -138,6 +138,12 @@ pub enum AppMessage { RecordingModeSelected(RecordingMode), /// Choose the friends presence posture (W7): invisible / normal / discoverable. PresenceModeSelected(PresenceMode), + /// Friends list (W7 P5): add-form edits, add, remove, and local rename. + FriendAddIdChanged(String), + FriendAddNameChanged(String), + AddFriend, + RemoveFriend(EndpointId), + RenameFriend(EndpointId, String), EventOccurred(Event), NavigateToSettings, NavigateBack, @@ -270,6 +276,13 @@ pub struct AppState { identity_error: Option, /// Whether the "Regenerate identity?" confirm modal is open. regenerate_identity_confirm_open: bool, + /// Saved friends (W7 P2/P5), loaded at startup, persisted on every change. + friends: crate::friends::FriendStore, + /// "Add friend" form inputs: their node id (hex) and an optional name. + friend_add_id: String, + friend_add_name: String, + /// Inline feedback for the add-friend form (e.g. a bad id), cleared on edit. + friend_add_error: Option, } impl AppState { @@ -367,6 +380,13 @@ impl Default for AppState { identity_persisted: true, identity_error: None, regenerate_identity_confirm_open: false, + friends: crate::friends::load().unwrap_or_else(|e| { + crate::log_msg(&format!("friends: failed to load, starting empty: {e:#}")); + crate::friends::FriendStore::default() + }), + friend_add_id: String::new(), + friend_add_name: String::new(), + friend_add_error: None, } } } @@ -742,6 +762,50 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // Persisted now; the live friends listener (P4, not yet wired) reads // this posture when it lands. No core command until then. } + AppMessage::FriendAddIdChanged(val) => { + state.friend_add_id = val; + state.friend_add_error = None; + } + AppMessage::FriendAddNameChanged(val) => { + state.friend_add_name = val; + state.friend_add_error = None; + } + AppMessage::AddFriend => { + let id_str = state.friend_add_id.trim(); + match id_str.parse::() { + Ok(id) if state.friends.contains(&id) => { + state.friend_add_error = Some("Already in your friends list.".to_string()); + } + Ok(id) => { + let name = { + let n = state.friend_add_name.trim(); + if n.is_empty() { short_id(id_str) } else { n.to_string() } + }; + state.friends.add(id, name, None); + if let Err(e) = crate::friends::save(&state.friends) { + crate::log_msg(&format!("friends: save failed: {e:#}")); + } + state.friend_add_id.clear(); + state.friend_add_name.clear(); + state.friend_add_error = None; + } + Err(_) => { + state.friend_add_error = Some("That doesn't look like a valid node ID.".to_string()); + } + } + } + AppMessage::RemoveFriend(id) => { + state.friends.remove(&id); + if let Err(e) = crate::friends::save(&state.friends) { + crate::log_msg(&format!("friends: save failed: {e:#}")); + } + } + AppMessage::RenameFriend(id, new_name) => { + state.friends.rename(&id, new_name); + if let Err(e) = crate::friends::save(&state.friends) { + crate::log_msg(&format!("friends: save failed: {e:#}")); + } + } AppMessage::ToggleNotifications(enabled) => { state.config.notifications_enabled = enabled; state.config.save(); @@ -1523,6 +1587,70 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { presence_radio(PresenceMode::Discoverable, "Discoverable — findable after a network change"), ].spacing(8).width(iced::Length::Fill); + // --- Friends (W7) — the durable address book; rooms are just labels. --- + let mut friend_rows = column![].spacing(6).width(iced::Length::Fill); + if state.friends.list().is_empty() { + friend_rows = friend_rows.push( + text("No friends yet. Add one by their node ID below.") + .size(12) + .color(color_subtext), + ); + } else { + for f in state.friends.list() { + let fid = f.id; + let id_short = format!("{}…", short_id(&f.id.to_string())); + friend_rows = friend_rows.push( + row![ + text_input("name", &f.name) + .on_input(move |v| AppMessage::RenameFriend(fid, v)) + .style(t_style) + .padding(6) + .width(iced::Length::FillPortion(2)), + text(id_short) + .size(11) + .color(color_subtext) + .width(iced::Length::FillPortion(2)), + button(text("Remove").size(12)) + .on_press(AppMessage::RemoveFriend(fid)) + .style(b_style(color_surface, color_maroon, color_text, 6.0)) + .padding(6), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center), + ); + } + } + let friend_add_error: Element = match &state.friend_add_error { + Some(e) => text(e).size(11).color(color_red).into(), + None => column![].into(), + }; + let friends_section = column![ + text("Save people by their node ID — this is the durable part; you \ + recognise friends here no matter what room you're in.") + .size(12) + .color(color_subtext), + friend_rows, + row![ + text_input("Friend's node ID", &state.friend_add_id) + .on_input(AppMessage::FriendAddIdChanged) + .style(t_style) + .padding(6) + .width(iced::Length::FillPortion(3)), + text_input("Name (optional)", &state.friend_add_name) + .on_input(AppMessage::FriendAddNameChanged) + .style(t_style) + .padding(6) + .width(iced::Length::FillPortion(2)), + button(text("Add").size(13)) + .on_press(AppMessage::AddFriend) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(8), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center), + friend_add_error, + ].spacing(8).width(iced::Length::Fill); + let settings_content = scrollable( column![ // --- Audio Devices --- @@ -1624,6 +1752,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { presence_section, vertical_space(section_gap), + // --- Friends --- + section_header("Friends"), + friends_section, + vertical_space(section_gap), + // --- Notifications & Sounds --- section_header("Notifications & Sounds"), column![