From 173585f1a0a6e0c5256c1e2f4a26e360a84333f5 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 16 Jun 2026 05:22:40 -0400 Subject: [PATCH] feat(w7): move Friends + Presence panel from Settings to the home screen Extract a self-contained friends_panel() (live list with presence + one-click Join, add-by-node-ID form, presence-posture radios) and place it as a side-by-side card next to the connect panel on the home screen, removing the equivalent sections from Settings. Friends are now visible without opening Settings. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 329 +++++++++++++++++++++++++++++-------------------- 1 file changed, 194 insertions(+), 135 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 2e851f8..1b91514 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1201,6 +1201,191 @@ fn vertical_space(height: f32) -> iced::widget::Space { iced::widget::Space::new().height(height) } +/// The Friends panel (W7) — moved from Settings to the home screen. One card with +/// the live friends list (presence status + one-click Join + inline rename/remove), +/// the add-by-node-ID form, and the presence posture selector. Self-contained: +/// recomputes the palette + the few style helpers it needs so it doesn't depend on +/// `view`'s locals. +fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { + let pal = state.config.theme.palette(); + let color_crust = pal.crust; + let color_mantle = pal.mantle; + let color_surface = pal.surface; + let color_overlay = pal.overlay; + let color_text = pal.text; + let color_subtext = pal.subtext; + let color_blue = pal.blue; + let color_red = pal.red; + let color_maroon = pal.maroon; + let color_green = pal.green; + + let c_style = move |bg: Color, b_color: Color, radius: f32| { + move |_theme: &Theme| container::Style { + text_color: Some(color_text), + background: Some(Background::Color(bg)), + border: Border { + color: b_color, + width: if b_color == Color::TRANSPARENT { 0.0 } else { 1.0 }, + radius: radius.into(), + }, + ..Default::default() + } + }; + let b_style = move |bg: Color, hover_bg: Color, text_c: Color, radius: f32| { + move |_theme: &Theme, status: button::Status| { + let active_bg = match status { + button::Status::Hovered => hover_bg, + _ => bg, + }; + button::Style { + background: Some(Background::Color(active_bg)), + text_color: text_c, + border: Border { color: Color::TRANSPARENT, width: 0.0, radius: radius.into() }, + ..Default::default() + } + } + }; + let t_style = move |_theme: &Theme, _status: text_input::Status| text_input::Style { + background: Background::Color(color_crust), + border: Border { color: color_surface, width: 1.0, radius: 6.0.into() }, + icon: color_subtext, + placeholder: color_overlay, + value: color_text, + selection: color_blue, + }; + + // The live friends list: status dot, inline rename, short id, remove. + 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())); + // Live presence (W7 B2); a missing entry = offline. In-room → Join. + let status: Element = match state.friend_presence.get(&fid) { + Some(crate::presence::FriendPresence::InRoom { name, ticket }) => { + let label = + if name.is_empty() { "in a room".to_string() } else { format!("in {name}") }; + row![ + text(label).size(11).color(color_green), + button(text("Join").size(12)) + .on_press(AppMessage::JoinFriendRoom(ticket.clone())) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(6), + ] + .spacing(6) + .align_y(iced::alignment::Vertical::Center) + .into() + } + Some(crate::presence::FriendPresence::Online) => { + text("● online").size(11).color(color_green).into() + } + None => text("○ offline").size(11).color(color_subtext).into(), + }; + 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::Fixed(140.0)), + status, + horizontal_space(), + text(id_short).size(11).color(color_subtext), + button(text("✕").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(), + }; + // A16: read-only when core couldn't load friends.json (never overwrite it). + let readonly_warning: Element = if state.friends_read_only { + text("⚠ friends.json couldn't load — read-only so it isn't overwritten. Fix or remove it, then restart.") + .size(11) + .color(color_red) + .into() + } else { + column![].into() + }; + + let add_form = column![ + text_input("Friend's node ID", &state.friend_add_id) + .on_input(AppMessage::FriendAddIdChanged) + .style(t_style) + .padding(6), + vertical_space(6.0), + row![ + text_input("Name (optional)", &state.friend_add_name) + .on_input(AppMessage::FriendAddNameChanged) + .style(t_style) + .padding(6) + .width(iced::Length::Fill), + 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(0) + .width(iced::Length::Fill); + + // Presence posture radios (each with a hover explainer). + let presence_radio = |mode: PresenceMode, label: &'static str| -> Element<'_, AppMessage> { + tooltip( + radio(label, mode, Some(state.config.presence_mode), AppMessage::PresenceModeSelected), + container(text(presence_mode_hint(mode)).size(11).color(color_text)) + .padding(8) + .max_width(300.0) + .style(c_style(color_crust, color_surface, 6.0)), + iced::widget::tooltip::Position::Bottom, + ) + .gap(8) + .into() + }; + + container( + column![ + text("FRIENDS").size(18).color(color_text), + text("Who's online — click Join to hop into a friend's room.") + .size(11) + .color(color_subtext), + vertical_space(10.0), + readonly_warning, + friend_rows, + vertical_space(12.0), + text("Add a friend").size(13).color(color_subtext), + add_form, + vertical_space(14.0), + text("Your presence").size(13).color(color_subtext), + presence_radio(PresenceMode::Normal, "Normal — friends only"), + presence_radio(PresenceMode::Invisible, "Invisible — appear offline"), + presence_radio(PresenceMode::Discoverable, "Discoverable — findable after a move"), + ] + .spacing(6), + ) + .style(c_style(color_mantle, color_surface, 12.0)) + .padding(24) + .width(460) + .into() +} + fn view(state: &AppState) -> Element<'_, AppMessage> { // Theme colours — sourced from the active palette (see `src/theme.rs`), so // all styling below re-themes when the user picks a different theme. @@ -1633,130 +1818,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { identity_warning, ].spacing(8).width(iced::Length::Fill); - // --- Presence (W7) — how reachable you are to friends while idle. --- - let presence_radio = |mode: PresenceMode, label: &'static str| -> Element<'_, AppMessage> { - tooltip( - radio(label, mode, Some(state.config.presence_mode), AppMessage::PresenceModeSelected), - container(text(presence_mode_hint(mode)).size(11).color(color_text)) - .padding(8) - .max_width(320.0) - .style(c_style(color_crust, color_surface, 6.0)), - iced::widget::tooltip::Position::Right, - ) - .gap(8) - .into() - }; - let presence_section = column![ - text("Controls whether friends can see you're online (and what room \ - you're in) while the app is open. Discovery stays off unless you \ - pick Discoverable.") - .size(12) - .color(color_subtext), - presence_radio(PresenceMode::Normal, "Normal — friends only, no beacon"), - presence_radio(PresenceMode::Invisible, "Invisible — appear offline"), - 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())); - // Live presence (W7 B2), from the latest FriendPresence event: a - // missing entry = offline. An in-room friend gets a one-click Join. - let status: Element = match state.friend_presence.get(&fid) { - Some(crate::presence::FriendPresence::InRoom { name, ticket }) => { - let label = - if name.is_empty() { "in a room".to_string() } else { format!("in {name}") }; - row![ - text(label).size(11).color(color_green), - button(text("Join").size(12)) - .on_press(AppMessage::JoinFriendRoom(ticket.clone())) - .style(b_style(color_surface, color_blue, color_text, 6.0)) - .padding(6), - ] - .spacing(6) - .align_y(iced::alignment::Vertical::Center) - .into() - } - Some(crate::presence::FriendPresence::Online) => { - text("● online").size(11).color(color_green).into() - } - None => text("○ offline").size(11).color(color_subtext).into(), - }; - 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::Fixed(240.0)), - button(text("Remove").size(12)) - .on_press(AppMessage::RemoveFriend(fid)) - .style(b_style(color_surface, color_maroon, color_text, 6.0)) - .padding(6), - text(id_short).size(11).color(color_subtext), - status, - // Absorb the remaining width so the row is left-aligned and - // nothing stretches to / under the scrollbar edge. - horizontal_space(), - ] - .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(), - }; - // A16: if core couldn't load friends.json it runs read-only so it never - // overwrites the damaged file — tell the user, since edits won't stick. - let friends_readonly_warning: Element = if state.friends_read_only { - text("⚠ Couldn't load friends.json — the list is read-only so the damaged \ - file isn't overwritten. Fix or remove it, then restart.") - .size(12) - .color(color_red) - .into() - } else { - 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), - friends_readonly_warning, - 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::Fixed(340.0)), - text_input("Name (optional)", &state.friend_add_name) - .on_input(AppMessage::FriendAddNameChanged) - .style(t_style) - .padding(6) - .width(iced::Length::Fixed(170.0)), - button(text("Add").size(13)) - .on_press(AppMessage::AddFriend) - .style(b_style(color_surface, color_blue, color_text, 6.0)) - .padding(8), - // Left-align the compact inputs; absorb the rest of the row. - horizontal_space(), - ] - .spacing(8) - .align_y(iced::alignment::Vertical::Center), - friend_add_error, - ].spacing(8).width(iced::Length::Fill); + // Presence + Friends moved to the home screen (see `friends_panel`). let settings_content = scrollable( column![ @@ -1854,15 +1916,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { identity_section, vertical_space(section_gap), - // --- Presence --- - section_header("Presence"), - presence_section, - vertical_space(section_gap), - - // --- Friends --- - section_header("Friends"), - friends_section, - vertical_space(section_gap), + // (Presence + Friends now live on the home screen.) // --- Notifications & Sounds --- section_header("Notifications & Sounds"), @@ -2020,9 +2074,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ) .style(c_style(color_mantle, color_surface, 12.0)) .padding(30) - .width(420); + .width(380); - let scroll = scrollable(content); + // Two side-by-side cards: connect (left) + the live friends list (right), + // centered together. The friends panel was moved here from Settings. + let panels = row![content, friends_panel(state)] + .spacing(20) + .align_y(iced::alignment::Vertical::Top); + let scroll = scrollable(container(panels).center_x(iced::Length::Fill)); let home = container( column![