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:
+133
@@ -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![
|
||||
|
||||
Reference in New Issue
Block a user