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),
|
RecordingModeSelected(RecordingMode),
|
||||||
/// Choose the friends presence posture (W7): invisible / normal / discoverable.
|
/// Choose the friends presence posture (W7): invisible / normal / discoverable.
|
||||||
PresenceModeSelected(PresenceMode),
|
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),
|
EventOccurred(Event),
|
||||||
NavigateToSettings,
|
NavigateToSettings,
|
||||||
NavigateBack,
|
NavigateBack,
|
||||||
@@ -270,6 +276,13 @@ pub struct AppState {
|
|||||||
identity_error: Option<String>,
|
identity_error: Option<String>,
|
||||||
/// Whether the "Regenerate identity?" confirm modal is open.
|
/// Whether the "Regenerate identity?" confirm modal is open.
|
||||||
regenerate_identity_confirm_open: bool,
|
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 {
|
impl AppState {
|
||||||
@@ -367,6 +380,13 @@ impl Default for AppState {
|
|||||||
identity_persisted: true,
|
identity_persisted: true,
|
||||||
identity_error: None,
|
identity_error: None,
|
||||||
regenerate_identity_confirm_open: false,
|
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
|
// Persisted now; the live friends listener (P4, not yet wired) reads
|
||||||
// this posture when it lands. No core command until then.
|
// 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) => {
|
AppMessage::ToggleNotifications(enabled) => {
|
||||||
state.config.notifications_enabled = enabled;
|
state.config.notifications_enabled = enabled;
|
||||||
state.config.save();
|
state.config.save();
|
||||||
@@ -1523,6 +1587,70 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
presence_radio(PresenceMode::Discoverable, "Discoverable — findable after a network change"),
|
presence_radio(PresenceMode::Discoverable, "Discoverable — findable after a network change"),
|
||||||
].spacing(8).width(iced::Length::Fill);
|
].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(
|
let settings_content = scrollable(
|
||||||
column![
|
column![
|
||||||
// --- Audio Devices ---
|
// --- Audio Devices ---
|
||||||
@@ -1624,6 +1752,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
presence_section,
|
presence_section,
|
||||||
vertical_space(section_gap),
|
vertical_space(section_gap),
|
||||||
|
|
||||||
|
// --- Friends ---
|
||||||
|
section_header("Friends"),
|
||||||
|
friends_section,
|
||||||
|
vertical_space(section_gap),
|
||||||
|
|
||||||
// --- Notifications & Sounds ---
|
// --- Notifications & Sounds ---
|
||||||
section_header("Notifications & Sounds"),
|
section_header("Notifications & Sounds"),
|
||||||
column![
|
column![
|
||||||
|
|||||||
Reference in New Issue
Block a user