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 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 04:47:56 -04:00
co-authored by Claude Opus 4.8
parent 47e7762437
commit f161534a4b
+133
View File
@@ -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<String>,
/// 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<String>,
}
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<AppMessage> {
// 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::<EndpointId>() {
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<AppMessage> = 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![