feat(presence): live friends listener + ownership move (W7 B2)
Makes the friends list live, building on the B1 persistent endpoint.
Friends ownership moves into core (was the GUI's):
- Core loads/owns friends.json behind a shared Mutex<FriendStore>; a malformed
load yields an empty store flagged READ-ONLY so we never overwrite the damaged
file (fixes backlog A16). New commands AddFriend/RemoveFriend/RenameFriend +
UiEvent::FriendsUpdated{friends,read_only}; the GUI is now a read-only mirror
that renders from the event and drives mutations via commands. The friends UI
shows a warning + blocks edits when read-only.
- Presence posture pushed to core via SetPresenceMode (persistence stays in
AppConfig); held in a shared Mutex for the listener/scheduler.
Live listener + outbound scheduler:
- New FriendsProtocol ProtocolHandler on the persistent Router for FRIENDS_ALPN
(the router owns accept(), so the listener can't be presence_net::serve — same
delegation pattern as B1's AudioRouter). Its reply policy reads the shared
friends/mode/current-room and uses presence::should_answer: answer friends only,
never while invisible, and report our current gathering's restamped member
ticket so a friend can one-click Join. handle()'s body is factored into a shared
exchange() used by both serve (tests) and FriendsProtocol.
- Outbound ping scheduler folded into the core loop via tokio::select! on a slow
interval (60s, first pass delayed 3s for endpoint online). FULLY DARK while
Invisible (no probing at all — user's choice). Each pass runs detached so it
never blocks command handling and picks up a rebuilt stack next tick; probes
friends with a saved addr in parallel and emits UiEvent::FriendPresence.
- note_seen auto-heal: a connected peer who is a friend has their last_addr
refreshed (+persisted) so the scheduler can reach them later.
- current_room shared state set on Join (restamped ticket) / cleared on Leave.
P5 UI: each friend shows online / offline / in-room with a one-click Join.
256 lib + 6 reconnect + 4 loopback + 2 ignored real-endpoint tests green, clippy
--all-targets clean, release builds. B2a (ownership/A16) is solo-verifiable; the
live listener + scheduler need the 2-machine field test before this merges.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+110
-32
@@ -146,6 +146,9 @@ pub enum AppMessage {
|
||||
AddFriend,
|
||||
RemoveFriend(EndpointId),
|
||||
RenameFriend(EndpointId, String),
|
||||
/// Join the gathering a friend is in (W7 B2), via the member ticket their
|
||||
/// presence reply carried. Mirrors a manual ticket join.
|
||||
JoinFriendRoom(String),
|
||||
EventOccurred(Event),
|
||||
NavigateToSettings,
|
||||
NavigateBack,
|
||||
@@ -278,8 +281,16 @@ 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.
|
||||
/// Saved friends (W7) — a READ-ONLY MIRROR of the core-owned store, refreshed by
|
||||
/// `UiEvent::FriendsUpdated`. The GUI no longer loads/saves it; add/remove/rename
|
||||
/// go to core as commands.
|
||||
friends: crate::friends::FriendStore,
|
||||
/// True when core couldn't load `friends.json` (malformed) and is in a degraded
|
||||
/// read-only state — the GUI disables edits + warns so we don't clobber it (A16).
|
||||
friends_read_only: bool,
|
||||
/// Latest live presence per friend (W7 B2), from `UiEvent::FriendPresence`. A
|
||||
/// missing entry = treat as offline/unknown.
|
||||
friend_presence: std::collections::HashMap<EndpointId, crate::presence::FriendPresence>,
|
||||
/// "Add friend" form inputs: their node id (hex) and an optional name.
|
||||
friend_add_id: String,
|
||||
friend_add_name: String,
|
||||
@@ -329,6 +340,7 @@ impl Default for AppState {
|
||||
let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode));
|
||||
let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
|
||||
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
|
||||
let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode));
|
||||
let pixelpass_available =
|
||||
crate::screenshare::is_available(config.pixelpass_path.as_deref());
|
||||
let all_devices = enumerate_audio_devices();
|
||||
@@ -382,10 +394,11 @@ 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()
|
||||
}),
|
||||
// Core owns the friends store now; the GUI starts empty and fills in
|
||||
// from the FriendsUpdated event core emits at startup.
|
||||
friends: crate::friends::FriendStore::default(),
|
||||
friends_read_only: false,
|
||||
friend_presence: std::collections::HashMap::new(),
|
||||
friend_add_id: String::new(),
|
||||
friend_add_name: String::new(),
|
||||
friend_add_error: None,
|
||||
@@ -683,6 +696,17 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.identity_persisted = persisted;
|
||||
state.identity_error = error;
|
||||
}
|
||||
UiEvent::FriendsUpdated { friends, read_only } => {
|
||||
// Core owns the store; mirror its snapshot. Drop presence for
|
||||
// anyone no longer a friend so the UI doesn't show a stale dot.
|
||||
let ids: HashSet<EndpointId> = friends.iter().map(|f| f.id).collect();
|
||||
state.friend_presence.retain(|id, _| ids.contains(id));
|
||||
state.friends.friends = friends;
|
||||
state.friends_read_only = read_only;
|
||||
}
|
||||
UiEvent::FriendPresence { id, presence } => {
|
||||
state.friend_presence.insert(id, presence);
|
||||
}
|
||||
UiEvent::Error(err) => {
|
||||
state.status_message = format!("Error: {}", err);
|
||||
}
|
||||
@@ -764,8 +788,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
AppMessage::PresenceModeSelected(mode) => {
|
||||
state.config.presence_mode = mode;
|
||||
state.config.save();
|
||||
// Persisted now; the live friends listener (P4, not yet wired) reads
|
||||
// this posture when it lands. No core command until then.
|
||||
// Push to core, which gates the live listener + ping scheduler (B2).
|
||||
let _ = state.controller.send(CoreCommand::SetPresenceMode(mode));
|
||||
}
|
||||
AppMessage::FriendAddIdChanged(val) => {
|
||||
state.friend_add_id = val;
|
||||
@@ -776,41 +800,60 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
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:#}"));
|
||||
// Core owns the store; the GUI just validates the id locally, then sends
|
||||
// a command. The mirror (and the cleared form) update on FriendsUpdated.
|
||||
if state.friends_read_only {
|
||||
state.friend_add_error =
|
||||
Some("Friends list is read-only (couldn't load friends.json).".to_string());
|
||||
} else {
|
||||
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() }
|
||||
};
|
||||
let _ = state.controller.send(CoreCommand::AddFriend { id, name, addr: None });
|
||||
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());
|
||||
}
|
||||
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:#}"));
|
||||
if !state.friends_read_only {
|
||||
let _ = state.controller.send(CoreCommand::RemoveFriend(id));
|
||||
}
|
||||
}
|
||||
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:#}"));
|
||||
if !state.friends_read_only {
|
||||
let _ = state.controller.send(CoreCommand::RenameFriend(id, new_name));
|
||||
}
|
||||
}
|
||||
AppMessage::JoinFriendRoom(ticket) => {
|
||||
// Join via the friend's member ticket (same path as a manual join).
|
||||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
||||
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
||||
state.status_message = "Joining your friend's room...".to_string();
|
||||
state.config.username = state.name.clone();
|
||||
state.config.save();
|
||||
state.mic_test_active = false;
|
||||
let _ = state.controller.send(CoreCommand::Join {
|
||||
name: state.name.clone(),
|
||||
ticket,
|
||||
input_device,
|
||||
output_device,
|
||||
echo_cancellation: state.config.echo_cancellation_enabled,
|
||||
avatar: state.config.avatar.clone(),
|
||||
});
|
||||
}
|
||||
AppMessage::ToggleNotifications(enabled) => {
|
||||
state.config.notifications_enabled = enabled;
|
||||
state.config.save();
|
||||
@@ -1626,6 +1669,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
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)
|
||||
@@ -1638,6 +1703,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.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(),
|
||||
@@ -1651,11 +1717,23 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user