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 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 05:22:40 -04:00
co-authored by Claude Opus 4.8
parent e47ea9c52f
commit 173585f1a0
+194 -135
View File
@@ -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<AppMessage> = 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<AppMessage> = 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<AppMessage> = 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<AppMessage> = 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<AppMessage> = 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<AppMessage> = 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![