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 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 04:43:34 -04:00
co-authored by Claude Opus 4.8
parent 87e15dd817
commit 47e7762437
+53
View File
@@ -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<AppMessage> {
// 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![