From 47e7762437addc42e58b0b1c05a12820bf0e2805 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Mon, 15 Jun 2026 04:43:34 -0400 Subject: [PATCH] feat(presence): presence-mode selector in Settings (W7 P5) Add a 'Presence' section to Settings with three radio options wired to the persisted PresenceMode (W7): Normal (friends only, no beacon; default), Invisible (appear offline to everyone), Discoverable (also publish so friends can find you after a network change). Each has a hover tooltip explainer, mirroring the recording-mode radios. Selecting one saves config.presence_mode. The live friends listener (P4, not yet wired) will read this posture when it lands; no core command until then. 256 lib tests green, clippy clean, release builds. Solo screenshot-verifiable (the live effect needs P4 + 2 machines). Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index 0a6fd47..9af919a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3,6 +3,7 @@ use crate::network::PeerState; use crate::notify::{self, Sound}; use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices}; use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout}; +use crate::presence::PresenceMode; use crate::theme::{AppTheme, Palette}; use iced::widget::{ @@ -135,6 +136,8 @@ pub enum AppMessage { NoiseGateDragging(f32), NetworkModeSelected(NetworkMode), RecordingModeSelected(RecordingMode), + /// Choose the friends presence posture (W7): invisible / normal / discoverable. + PresenceModeSelected(PresenceMode), EventOccurred(Event), NavigateToSettings, NavigateBack, @@ -733,6 +736,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // Takes effect on the next recording start. let _ = state.controller.send(CoreCommand::SetRecordingMode(mode)); } + 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. + } AppMessage::ToggleNotifications(enabled) => { state.config.notifications_enabled = enabled; state.config.save(); @@ -1009,6 +1018,21 @@ fn recording_mode_hint(mode: RecordingMode) -> &'static str { } } +/// One-line explanation of a presence posture for the settings picker (W7). +fn presence_mode_hint(mode: PresenceMode) -> &'static str { + match mode { + PresenceMode::Invisible => { + "Answer no one — appear offline to everyone, even friends." + } + PresenceMode::Normal => { + "Answer friends only, from your saved address. No presence beacon." + } + PresenceMode::Discoverable => { + "Also publish so friends can still find you after you change networks (opt-in)." + } + } +} + /// Formats a call duration as `m:ss` (or `h:mm:ss` past an hour). fn format_duration(total_secs: u64) -> String { let h = total_secs / 3600; @@ -1475,6 +1499,30 @@ 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); + let settings_content = scrollable( column![ // --- Audio Devices --- @@ -1571,6 +1619,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { identity_section, vertical_space(section_gap), + // --- Presence --- + section_header("Presence"), + presence_section, + vertical_space(section_gap), + // --- Notifications & Sounds --- section_header("Notifications & Sounds"), column![