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