This commit is contained in:
@@ -4,6 +4,17 @@ All notable changes to PeerSpeak are documented here.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Chat message sounds.** Successful outgoing messages and admitted incoming
|
||||||
|
messages now have distinct notification chimes, each with its own enable
|
||||||
|
toggle and optional custom WAV path in Notifications settings.
|
||||||
|
- **Contact presence sounds.** The home-screen contacts list now announces a
|
||||||
|
contact becoming online or offline. Initial online contacts are announced;
|
||||||
|
initial offline results stay silent. Both events have independent toggles and
|
||||||
|
optional custom WAV paths.
|
||||||
|
- **Notification sound browser.** Every notification event now has a native
|
||||||
|
Browse button for choosing a custom WAV instead of typing its path manually.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Low-latency screen sharing stays near the live edge again.** mpv's
|
- **Low-latency screen sharing stays near the live edge again.** mpv's
|
||||||
timestamp pacing could let stale frames accumulate across the reliable
|
timestamp pacing could let stale frames accumulate across the reliable
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -76,6 +76,14 @@ CHIMES = {
|
|||||||
"mic-toggle.wav": [(E5, 0.08)],
|
"mic-toggle.wav": [(E5, 0.08)],
|
||||||
# Reconnect gave up: disappointing low two-note fall.
|
# Reconnect gave up: disappointing low two-note fall.
|
||||||
"reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)],
|
"reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)],
|
||||||
|
# Our chat message entered the room: a tiny bright acknowledgement.
|
||||||
|
"chat-sent.wav": [(1046.50, 0.06)],
|
||||||
|
# A peer message arrived: a soft two-note lift, distinct but unobtrusive.
|
||||||
|
"chat-received.wav": [(E5, 0.07), (G5, 0.11)],
|
||||||
|
# A saved contact came online: a light, higher two-note arrival.
|
||||||
|
"contact-online.wav": [(E5, 0.09), (880.00, 0.18)],
|
||||||
|
# A saved contact went offline: the same tonal family falling away.
|
||||||
|
"contact-offline.wav": [(E5, 0.09), (440.00, 0.18)],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+232
-22
@@ -760,6 +760,10 @@ pub enum AppMessage {
|
|||||||
ToggleNotifications(bool),
|
ToggleNotifications(bool),
|
||||||
ToggleEchoCancellation(bool),
|
ToggleEchoCancellation(bool),
|
||||||
CustomSoundPathChanged(Sound, String),
|
CustomSoundPathChanged(Sound, String),
|
||||||
|
/// Open a native WAV picker for one notification event.
|
||||||
|
BrowseCustomSound(Sound),
|
||||||
|
/// Result of the notification WAV picker (`None` = cancelled).
|
||||||
|
CustomSoundFilePicked(Sound, Option<std::path::PathBuf>),
|
||||||
/// Toggle the per-sound enable flag for a single chime (W6).
|
/// Toggle the per-sound enable flag for a single chime (W6).
|
||||||
ToggleSoundEnabled(Sound, bool),
|
ToggleSoundEnabled(Sound, bool),
|
||||||
/// Open / cancel the "Regenerate identity?" confirm modal (W7).
|
/// Open / cancel the "Regenerate identity?" confirm modal (W7).
|
||||||
@@ -1339,16 +1343,20 @@ impl AppState {
|
|||||||
/// so Retry can re-dispatch, unless the entry is already gone (history
|
/// so Retry can re-dispatch, unless the entry is already gone (history
|
||||||
/// eviction / room reset), in which case the payload is dropped so its map
|
/// eviction / room reset), in which case the payload is dropped so its map
|
||||||
/// can't leak. Either way an id with no matching entry is a harmless no-op.
|
/// can't leak. Either way an id with no matching entry is a harmless no-op.
|
||||||
fn apply_send_result(&mut self, local_id: u64, error: Option<String>) {
|
/// Returns `true` only when a successful result matched a live local echo,
|
||||||
|
/// which is the boundary used for the outgoing-message notification.
|
||||||
|
fn apply_send_result(&mut self, local_id: u64, error: Option<String>) -> bool {
|
||||||
match error {
|
match error {
|
||||||
None => {
|
None => {
|
||||||
self.set_send_status(local_id, SendStatus::Broadcast);
|
let matched = self.set_send_status(local_id, SendStatus::Broadcast);
|
||||||
self.send_payloads.remove(&local_id);
|
self.send_payloads.remove(&local_id);
|
||||||
|
matched
|
||||||
}
|
}
|
||||||
Some(e) => {
|
Some(e) => {
|
||||||
if !self.set_send_status(local_id, SendStatus::Failed(e)) {
|
if !self.set_send_status(local_id, SendStatus::Failed(e)) {
|
||||||
self.send_payloads.remove(&local_id);
|
self.send_payloads.remove(&local_id);
|
||||||
}
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1379,9 +1387,30 @@ impl AppState {
|
|||||||
Sound::SelfLeave => &self.config.custom_sound_self_leave,
|
Sound::SelfLeave => &self.config.custom_sound_self_leave,
|
||||||
Sound::MicToggle => &self.config.custom_sound_mic_toggle,
|
Sound::MicToggle => &self.config.custom_sound_mic_toggle,
|
||||||
Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed,
|
Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed,
|
||||||
|
Sound::ChatSent => &self.config.custom_sound_chat_sent,
|
||||||
|
Sound::ChatReceived => &self.config.custom_sound_chat_received,
|
||||||
|
Sound::ContactOnline => &self.config.custom_sound_contact_online,
|
||||||
|
Sound::ContactOffline => &self.config.custom_sound_contact_offline,
|
||||||
};
|
};
|
||||||
opt.as_deref().unwrap_or("")
|
opt.as_deref().unwrap_or("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_custom_sound_path(&mut self, sound: Sound, path: Option<String>) {
|
||||||
|
match sound {
|
||||||
|
Sound::SelfJoin => self.config.custom_sound_self_join = path,
|
||||||
|
Sound::PeerJoin => self.config.custom_sound_peer_join = path,
|
||||||
|
Sound::PeerLeave => self.config.custom_sound_peer_leave = path,
|
||||||
|
Sound::ReconnectAttempt => self.config.custom_sound_reconnect_attempt = path,
|
||||||
|
Sound::Reconnected => self.config.custom_sound_reconnected = path,
|
||||||
|
Sound::SelfLeave => self.config.custom_sound_self_leave = path,
|
||||||
|
Sound::MicToggle => self.config.custom_sound_mic_toggle = path,
|
||||||
|
Sound::ReconnectFailed => self.config.custom_sound_reconnect_failed = path,
|
||||||
|
Sound::ChatSent => self.config.custom_sound_chat_sent = path,
|
||||||
|
Sound::ChatReceived => self.config.custom_sound_chat_received = path,
|
||||||
|
Sound::ContactOnline => self.config.custom_sound_contact_online = path,
|
||||||
|
Sound::ContactOffline => self.config.custom_sound_contact_offline = path,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppState {
|
impl Default for AppState {
|
||||||
@@ -1871,6 +1900,47 @@ fn reconnected_chime(
|
|||||||
was_reconnect.then_some(Sound::Reconnected)
|
was_reconnect.then_some(Sound::Reconnected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the landing-page contact chime for one definitive presence update.
|
||||||
|
/// An initial online result is an arrival (so contacts already online at app
|
||||||
|
/// startup are announced), while an initial offline result is silent. Online
|
||||||
|
/// includes both plain `Online` and `InRoom`; moving between those two states is
|
||||||
|
/// not a connection transition. Updates continue to populate the presence map
|
||||||
|
/// off-home, but notification sounds are intentionally limited to the home page.
|
||||||
|
fn friend_presence_notification(
|
||||||
|
screen: Screen,
|
||||||
|
previous: Option<&crate::presence::FriendPresence>,
|
||||||
|
next: &crate::presence::FriendPresence,
|
||||||
|
) -> Option<Sound> {
|
||||||
|
if screen != Screen::Home {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let online = |presence: &crate::presence::FriendPresence| {
|
||||||
|
matches!(
|
||||||
|
presence,
|
||||||
|
crate::presence::FriendPresence::Online
|
||||||
|
| crate::presence::FriendPresence::InRoom { .. }
|
||||||
|
)
|
||||||
|
};
|
||||||
|
match (previous.map(online), online(next)) {
|
||||||
|
(None | Some(false), true) => Some(Sound::ContactOnline),
|
||||||
|
(Some(true), false) => Some(Sound::ContactOffline),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a native picker result into the persisted notification path. The
|
||||||
|
/// dialog filter is advisory on some desktops, so enforce WAV here as well.
|
||||||
|
/// `None` (cancel) and a non-WAV selection leave the existing setting untouched.
|
||||||
|
fn selected_wav_path(picked: Option<std::path::PathBuf>) -> Option<String> {
|
||||||
|
let path = picked?;
|
||||||
|
let is_wav = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("wav"));
|
||||||
|
is_wav.then(|| path.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
fn in_call(state: &AppState) -> bool {
|
fn in_call(state: &AppState) -> bool {
|
||||||
!state.ticket.is_empty()
|
!state.ticket.is_empty()
|
||||||
}
|
}
|
||||||
@@ -2310,7 +2380,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
// failure it's retained for Retry — unless the entry is gone
|
// failure it's retained for Retry — unless the entry is gone
|
||||||
// (history eviction / room reset), in which case drop it so
|
// (history eviction / room reset), in which case drop it so
|
||||||
// the payload map can't leak.
|
// the payload map can't leak.
|
||||||
state.apply_send_result(local_id, error);
|
if state.apply_send_result(local_id, error) {
|
||||||
|
notify::play(
|
||||||
|
Sound::ChatSent,
|
||||||
|
state.config.custom_sound_chat_sent.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
UiEvent::ChatMessage {
|
UiEvent::ChatMessage {
|
||||||
from,
|
from,
|
||||||
@@ -2343,6 +2418,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
local_send: None,
|
local_send: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
notify::play(
|
||||||
|
Sound::ChatReceived,
|
||||||
|
state.config.custom_sound_chat_received.as_deref(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiEvent::AttachmentReady { from, id, data } => {
|
UiEvent::AttachmentReady { from, id, data } => {
|
||||||
@@ -2512,7 +2591,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.friends_read_only = read_only;
|
state.friends_read_only = read_only;
|
||||||
}
|
}
|
||||||
UiEvent::FriendPresence { id, presence } => {
|
UiEvent::FriendPresence { id, presence } => {
|
||||||
|
let sound = friend_presence_notification(
|
||||||
|
state.current_screen,
|
||||||
|
state.friend_presence.get(&id),
|
||||||
|
&presence,
|
||||||
|
);
|
||||||
state.friend_presence.insert(id, presence);
|
state.friend_presence.insert(id, presence);
|
||||||
|
if let Some(sound) = sound {
|
||||||
|
notify::play(sound, Some(state.custom_sound_path(sound)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
UiEvent::FriendsRescanned => {
|
UiEvent::FriendsRescanned => {
|
||||||
// The manual pass finished. Stamp the time for the live "scanned
|
// The manual pass finished. Stamp the time for the live "scanned
|
||||||
@@ -2887,15 +2974,36 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
} else {
|
} else {
|
||||||
Some(path)
|
Some(path)
|
||||||
};
|
};
|
||||||
match sound {
|
state.set_custom_sound_path(sound, path_opt);
|
||||||
Sound::SelfJoin => state.config.custom_sound_self_join = path_opt,
|
}
|
||||||
Sound::PeerJoin => state.config.custom_sound_peer_join = path_opt,
|
AppMessage::BrowseCustomSound(sound) => {
|
||||||
Sound::PeerLeave => state.config.custom_sound_peer_leave = path_opt,
|
let initial_dir = {
|
||||||
Sound::ReconnectAttempt => state.config.custom_sound_reconnect_attempt = path_opt,
|
let current = state.custom_sound_path(sound);
|
||||||
Sound::Reconnected => state.config.custom_sound_reconnected = path_opt,
|
(!current.trim().is_empty())
|
||||||
Sound::SelfLeave => state.config.custom_sound_self_leave = path_opt,
|
.then(|| notify::expand_tilde(current))
|
||||||
Sound::MicToggle => state.config.custom_sound_mic_toggle = path_opt,
|
.and_then(|path| path.parent().map(std::path::Path::to_path_buf))
|
||||||
Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt,
|
.filter(|path| path.is_dir())
|
||||||
|
};
|
||||||
|
return Task::perform(
|
||||||
|
async move {
|
||||||
|
let mut dialog = rfd::AsyncFileDialog::new()
|
||||||
|
.add_filter("WAV audio", &["wav"])
|
||||||
|
.set_title("Choose a notification sound");
|
||||||
|
if let Some(dir) = initial_dir {
|
||||||
|
dialog = dialog.set_directory(dir);
|
||||||
|
}
|
||||||
|
dialog
|
||||||
|
.pick_file()
|
||||||
|
.await
|
||||||
|
.map(|handle| handle.path().to_path_buf())
|
||||||
|
},
|
||||||
|
move |picked| AppMessage::CustomSoundFilePicked(sound, picked),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
AppMessage::CustomSoundFilePicked(sound, picked) => {
|
||||||
|
if let Some(path) = selected_wav_path(picked) {
|
||||||
|
state.set_custom_sound_path(sound, Some(path));
|
||||||
|
state.config.save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppMessage::ToggleSoundEnabled(sound, enabled) => {
|
AppMessage::ToggleSoundEnabled(sound, enabled) => {
|
||||||
@@ -5282,10 +5390,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
]
|
]
|
||||||
.spacing(6)
|
.spacing(6)
|
||||||
.align_y(iced::alignment::Vertical::Center),
|
.align_y(iced::alignment::Vertical::Center),
|
||||||
|
row![
|
||||||
context_input("Default (embedded)...", path)
|
context_input("Default (embedded)...", path)
|
||||||
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
|
.width(iced::Length::Fill),
|
||||||
|
button(text("Browse…").size(11))
|
||||||
|
.on_press(AppMessage::BrowseCustomSound(sound))
|
||||||
|
.style(b_style(color_surface, color_blue, color_text, 5.0))
|
||||||
|
.padding([8, 10]),
|
||||||
|
]
|
||||||
|
.spacing(6)
|
||||||
|
.width(iced::Length::Fill)
|
||||||
]
|
]
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
@@ -6035,6 +6152,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
path_field("Mic Toggle", Sound::MicToggle),
|
path_field("Mic Toggle", Sound::MicToggle),
|
||||||
path_field("Reconnect Failed", Sound::ReconnectFailed),
|
path_field("Reconnect Failed", Sound::ReconnectFailed),
|
||||||
].spacing(20).width(iced::Length::Fill),
|
].spacing(20).width(iced::Length::Fill),
|
||||||
|
row![
|
||||||
|
path_field("Chat Sent", Sound::ChatSent),
|
||||||
|
path_field("Chat Received", Sound::ChatReceived),
|
||||||
|
].spacing(20).width(iced::Length::Fill),
|
||||||
|
row![
|
||||||
|
path_field("Contact Online", Sound::ContactOnline),
|
||||||
|
path_field("Contact Offline", Sound::ContactOffline),
|
||||||
|
].spacing(20).width(iced::Length::Fill),
|
||||||
].spacing(8).width(iced::Length::Fill),
|
].spacing(8).width(iced::Length::Fill),
|
||||||
]
|
]
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
@@ -9544,12 +9669,12 @@ mod tests {
|
|||||||
use super::sendqueue::{self, LocalSend, SendStatus};
|
use super::sendqueue::{self, LocalSend, SendStatus};
|
||||||
use super::{
|
use super::{
|
||||||
AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState,
|
AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState,
|
||||||
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX,
|
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, Screen,
|
||||||
ScreenBounds, UiEvent, attachment_default_name, clamp_window_position,
|
ScreenBounds, UiEvent, attachment_default_name, clamp_window_position,
|
||||||
clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration,
|
clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration,
|
||||||
format_relative_ago, initial_window_position, now_playing_label, reconnect_attempt_chime,
|
format_relative_ago, friend_presence_notification, initial_window_position,
|
||||||
reconnected_chime, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning,
|
now_playing_label, reconnect_attempt_chime, reconnected_chime, selected_wav_path,
|
||||||
update,
|
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update,
|
||||||
};
|
};
|
||||||
use iroh::SecretKey;
|
use iroh::SecretKey;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
@@ -10640,6 +10765,91 @@ mod tests {
|
|||||||
|
|
||||||
const W: f32 = 200.0;
|
const W: f32 = 200.0;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_contact_presence_announces_only_online() {
|
||||||
|
use crate::presence::FriendPresence;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
friend_presence_notification(Screen::Home, None, &FriendPresence::Online),
|
||||||
|
Some(Sound::ContactOnline)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
friend_presence_notification(Screen::Home, None, &FriendPresence::Offline),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_presence_chimes_only_on_online_boundary() {
|
||||||
|
use crate::presence::FriendPresence;
|
||||||
|
|
||||||
|
let in_room = FriendPresence::InRoom {
|
||||||
|
name: "Game night".to_string(),
|
||||||
|
ticket: "ticket".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
friend_presence_notification(Screen::Home, Some(&FriendPresence::Offline), &in_room,),
|
||||||
|
Some(Sound::ContactOnline)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
friend_presence_notification(
|
||||||
|
Screen::Home,
|
||||||
|
Some(&FriendPresence::Online),
|
||||||
|
&FriendPresence::Offline,
|
||||||
|
),
|
||||||
|
Some(Sound::ContactOffline)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
friend_presence_notification(Screen::Home, Some(&FriendPresence::Online), &in_room,),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
friend_presence_notification(
|
||||||
|
Screen::Home,
|
||||||
|
Some(&FriendPresence::Offline),
|
||||||
|
&FriendPresence::Offline,
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_presence_is_silent_away_from_landing_page() {
|
||||||
|
use crate::presence::FriendPresence;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
friend_presence_notification(Screen::Room, None, &FriendPresence::Online),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
friend_presence_notification(
|
||||||
|
Screen::Settings,
|
||||||
|
Some(&FriendPresence::Online),
|
||||||
|
&FriendPresence::Offline,
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_notification_sound_accepts_wav_and_preserves_cancel() {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
assert_eq!(selected_wav_path(None), None);
|
||||||
|
assert_eq!(
|
||||||
|
selected_wav_path(Some(PathBuf::from("/tmp/notify.mp3"))),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
selected_wav_path(Some(PathBuf::from("/tmp/notify.wav"))),
|
||||||
|
Some("/tmp/notify.wav".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
selected_wav_path(Some(PathBuf::from("/tmp/notify.WAV"))),
|
||||||
|
Some("/tmp/notify.WAV".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gate_drag_maps_left_edge_to_zero() {
|
fn gate_drag_maps_left_edge_to_zero() {
|
||||||
assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0);
|
assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0);
|
||||||
@@ -10923,7 +11133,7 @@ mod tests {
|
|||||||
// Empty queue + a fresh full pacer → dispatched immediately.
|
// Empty queue + a fresh full pacer → dispatched immediately.
|
||||||
assert_eq!(status_of(&state, id), Some(SendStatus::Pending));
|
assert_eq!(status_of(&state, id), Some(SendStatus::Pending));
|
||||||
assert!(state.send_payloads.contains_key(&id));
|
assert!(state.send_payloads.contains_key(&id));
|
||||||
state.apply_send_result(id, None);
|
assert!(state.apply_send_result(id, None));
|
||||||
assert_eq!(status_of(&state, id), Some(SendStatus::Broadcast));
|
assert_eq!(status_of(&state, id), Some(SendStatus::Broadcast));
|
||||||
// A completed send releases its retry payload.
|
// A completed send releases its retry payload.
|
||||||
assert!(!state.send_payloads.contains_key(&id));
|
assert!(!state.send_payloads.contains_key(&id));
|
||||||
@@ -10934,7 +11144,7 @@ mod tests {
|
|||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
let id = push_own(&mut state, "yo");
|
let id = push_own(&mut state, "yo");
|
||||||
state.submit_send(id, PendingSend::Text("yo".to_string()));
|
state.submit_send(id, PendingSend::Text("yo".to_string()));
|
||||||
state.apply_send_result(id, Some("not in a room".to_string()));
|
assert!(!state.apply_send_result(id, Some("not in a room".to_string())));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
status_of(&state, id),
|
status_of(&state, id),
|
||||||
Some(SendStatus::Failed("not in a room".to_string()))
|
Some(SendStatus::Failed("not in a room".to_string()))
|
||||||
@@ -10950,11 +11160,11 @@ mod tests {
|
|||||||
state.submit_send(a, PendingSend::Text("a".to_string()));
|
state.submit_send(a, PendingSend::Text("a".to_string()));
|
||||||
let b = push_own(&mut state, "b");
|
let b = push_own(&mut state, "b");
|
||||||
state.submit_send(b, PendingSend::Text("b".to_string()));
|
state.submit_send(b, PendingSend::Text("b".to_string()));
|
||||||
state.apply_send_result(a, None);
|
assert!(state.apply_send_result(a, None));
|
||||||
assert_eq!(status_of(&state, a), Some(SendStatus::Broadcast));
|
assert_eq!(status_of(&state, a), Some(SendStatus::Broadcast));
|
||||||
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
|
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
|
||||||
// A result for an id with no matching entry is a harmless no-op.
|
// A result for an id with no matching entry is a harmless no-op.
|
||||||
state.apply_send_result(9999, None);
|
assert!(!state.apply_send_result(9999, None));
|
||||||
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
|
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10968,7 +11178,7 @@ mod tests {
|
|||||||
state
|
state
|
||||||
.chat_messages
|
.chat_messages
|
||||||
.retain(|m| m.local_send.as_ref().map(|s| s.id) != Some(id));
|
.retain(|m| m.local_send.as_ref().map(|s| s.id) != Some(id));
|
||||||
state.apply_send_result(id, Some("dead".to_string()));
|
assert!(!state.apply_send_result(id, Some("dead".to_string())));
|
||||||
// No entry to mark → the payload must not leak.
|
// No entry to mark → the payload must not leak.
|
||||||
assert!(!state.send_payloads.contains_key(&id));
|
assert!(!state.send_payloads.contains_key(&id));
|
||||||
}
|
}
|
||||||
@@ -10983,7 +11193,7 @@ mod tests {
|
|||||||
assert!(state.send_queue.is_empty());
|
assert!(state.send_queue.is_empty());
|
||||||
assert!(state.send_payloads.is_empty());
|
assert!(state.send_payloads.is_empty());
|
||||||
// A late result for the pre-reset send touches nothing and adds no entry.
|
// A late result for the pre-reset send touches nothing and adds no entry.
|
||||||
state.apply_send_result(id, None);
|
assert!(!state.apply_send_result(id, None));
|
||||||
assert!(state.chat_messages.is_empty());
|
assert!(state.chat_messages.is_empty());
|
||||||
assert!(state.send_payloads.is_empty());
|
assert!(state.send_payloads.is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -478,6 +478,14 @@ pub struct AppConfig {
|
|||||||
pub custom_sound_mic_toggle: Option<String>,
|
pub custom_sound_mic_toggle: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_sound_reconnect_failed: Option<String>,
|
pub custom_sound_reconnect_failed: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_sound_chat_sent: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_sound_chat_received: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_sound_contact_online: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_sound_contact_offline: Option<String>,
|
||||||
/// Per-sound enable flags (W6). The master `notifications_enabled` toggle
|
/// Per-sound enable flags (W6). The master `notifications_enabled` toggle
|
||||||
/// gates ALL chimes; these let the user silence individual events while the
|
/// gates ALL chimes; these let the user silence individual events while the
|
||||||
/// master stays on. A chime plays only if the master AND its flag are true.
|
/// master stays on. A chime plays only if the master AND its flag are true.
|
||||||
@@ -498,6 +506,14 @@ pub struct AppConfig {
|
|||||||
pub sound_mic_toggle_enabled: bool,
|
pub sound_mic_toggle_enabled: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub sound_reconnect_failed_enabled: bool,
|
pub sound_reconnect_failed_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub sound_chat_sent_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub sound_chat_received_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub sound_contact_online_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub sound_contact_offline_enabled: bool,
|
||||||
/// Optional override for the `pixelpass` binary location (screen share).
|
/// Optional override for the `pixelpass` binary location (screen share).
|
||||||
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
|
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -601,6 +617,10 @@ impl Default for AppConfig {
|
|||||||
custom_sound_self_leave: None,
|
custom_sound_self_leave: None,
|
||||||
custom_sound_mic_toggle: None,
|
custom_sound_mic_toggle: None,
|
||||||
custom_sound_reconnect_failed: None,
|
custom_sound_reconnect_failed: None,
|
||||||
|
custom_sound_chat_sent: None,
|
||||||
|
custom_sound_chat_received: None,
|
||||||
|
custom_sound_contact_online: None,
|
||||||
|
custom_sound_contact_offline: None,
|
||||||
sound_self_join_enabled: true,
|
sound_self_join_enabled: true,
|
||||||
sound_peer_join_enabled: true,
|
sound_peer_join_enabled: true,
|
||||||
sound_peer_leave_enabled: true,
|
sound_peer_leave_enabled: true,
|
||||||
@@ -609,6 +629,10 @@ impl Default for AppConfig {
|
|||||||
sound_self_leave_enabled: true,
|
sound_self_leave_enabled: true,
|
||||||
sound_mic_toggle_enabled: true,
|
sound_mic_toggle_enabled: true,
|
||||||
sound_reconnect_failed_enabled: true,
|
sound_reconnect_failed_enabled: true,
|
||||||
|
sound_chat_sent_enabled: true,
|
||||||
|
sound_chat_received_enabled: true,
|
||||||
|
sound_contact_online_enabled: true,
|
||||||
|
sound_contact_offline_enabled: true,
|
||||||
pixelpass_path: None,
|
pixelpass_path: None,
|
||||||
screen_share: ScreenShareSettings::default(),
|
screen_share: ScreenShareSettings::default(),
|
||||||
recents: Vec::new(),
|
recents: Vec::new(),
|
||||||
@@ -639,6 +663,10 @@ impl AppConfig {
|
|||||||
Sound::SelfLeave => self.sound_self_leave_enabled,
|
Sound::SelfLeave => self.sound_self_leave_enabled,
|
||||||
Sound::MicToggle => self.sound_mic_toggle_enabled,
|
Sound::MicToggle => self.sound_mic_toggle_enabled,
|
||||||
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled,
|
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled,
|
||||||
|
Sound::ChatSent => self.sound_chat_sent_enabled,
|
||||||
|
Sound::ChatReceived => self.sound_chat_received_enabled,
|
||||||
|
Sound::ContactOnline => self.sound_contact_online_enabled,
|
||||||
|
Sound::ContactOffline => self.sound_contact_offline_enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,6 +681,10 @@ impl AppConfig {
|
|||||||
Sound::SelfLeave => self.sound_self_leave_enabled = enabled,
|
Sound::SelfLeave => self.sound_self_leave_enabled = enabled,
|
||||||
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
|
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
|
||||||
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled = enabled,
|
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled = enabled,
|
||||||
|
Sound::ChatSent => self.sound_chat_sent_enabled = enabled,
|
||||||
|
Sound::ChatReceived => self.sound_chat_received_enabled = enabled,
|
||||||
|
Sound::ContactOnline => self.sound_contact_online_enabled = enabled,
|
||||||
|
Sound::ContactOffline => self.sound_contact_offline_enabled = enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,6 +972,10 @@ mod tests {
|
|||||||
assert!(deserialized.custom_sound_self_leave.is_none());
|
assert!(deserialized.custom_sound_self_leave.is_none());
|
||||||
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
||||||
assert!(deserialized.custom_sound_reconnect_failed.is_none());
|
assert!(deserialized.custom_sound_reconnect_failed.is_none());
|
||||||
|
assert!(deserialized.custom_sound_chat_sent.is_none());
|
||||||
|
assert!(deserialized.custom_sound_chat_received.is_none());
|
||||||
|
assert!(deserialized.custom_sound_contact_online.is_none());
|
||||||
|
assert!(deserialized.custom_sound_contact_offline.is_none());
|
||||||
assert_eq!(deserialized.screen_share, ScreenShareSettings::default());
|
assert_eq!(deserialized.screen_share, ScreenShareSettings::default());
|
||||||
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
|
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
|
||||||
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
|
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
|
||||||
|
|||||||
+25
-1
@@ -80,6 +80,14 @@ pub enum Sound {
|
|||||||
MicToggle,
|
MicToggle,
|
||||||
/// Reconnect failed / peer evicted.
|
/// Reconnect failed / peer evicted.
|
||||||
ReconnectFailed,
|
ReconnectFailed,
|
||||||
|
/// One of our chat messages was broadcast to the room.
|
||||||
|
ChatSent,
|
||||||
|
/// A chat message from another participant was admitted.
|
||||||
|
ChatReceived,
|
||||||
|
/// A saved contact was detected online on the home screen.
|
||||||
|
ContactOnline,
|
||||||
|
/// A saved contact previously seen online went offline on the home screen.
|
||||||
|
ContactOffline,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sound {
|
impl Sound {
|
||||||
@@ -93,10 +101,14 @@ impl Sound {
|
|||||||
Sound::SelfLeave,
|
Sound::SelfLeave,
|
||||||
Sound::MicToggle,
|
Sound::MicToggle,
|
||||||
Sound::ReconnectFailed,
|
Sound::ReconnectFailed,
|
||||||
|
Sound::ChatSent,
|
||||||
|
Sound::ChatReceived,
|
||||||
|
Sound::ContactOnline,
|
||||||
|
Sound::ContactOffline,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Number of distinct notification events.
|
/// Number of distinct notification events.
|
||||||
pub const COUNT: usize = 8;
|
pub const COUNT: usize = 12;
|
||||||
|
|
||||||
/// Stable 0-based index into the per-sound flag array. Must match `ALL`.
|
/// Stable 0-based index into the per-sound flag array. Must match `ALL`.
|
||||||
fn index(self) -> usize {
|
fn index(self) -> usize {
|
||||||
@@ -109,6 +121,10 @@ impl Sound {
|
|||||||
Sound::SelfLeave => 5,
|
Sound::SelfLeave => 5,
|
||||||
Sound::MicToggle => 6,
|
Sound::MicToggle => 6,
|
||||||
Sound::ReconnectFailed => 7,
|
Sound::ReconnectFailed => 7,
|
||||||
|
Sound::ChatSent => 8,
|
||||||
|
Sound::ChatReceived => 9,
|
||||||
|
Sound::ContactOnline => 10,
|
||||||
|
Sound::ContactOffline => 11,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +139,10 @@ impl Sound {
|
|||||||
Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"),
|
Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"),
|
||||||
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
|
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
|
||||||
Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"),
|
Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"),
|
||||||
|
Sound::ChatSent => include_bytes!("../assets/sounds/chat-sent.wav"),
|
||||||
|
Sound::ChatReceived => include_bytes!("../assets/sounds/chat-received.wav"),
|
||||||
|
Sound::ContactOnline => include_bytes!("../assets/sounds/contact-online.wav"),
|
||||||
|
Sound::ContactOffline => include_bytes!("../assets/sounds/contact-offline.wav"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +157,10 @@ impl Sound {
|
|||||||
Sound::SelfLeave => "self-leave",
|
Sound::SelfLeave => "self-leave",
|
||||||
Sound::MicToggle => "mic-toggle",
|
Sound::MicToggle => "mic-toggle",
|
||||||
Sound::ReconnectFailed => "reconnect-failed",
|
Sound::ReconnectFailed => "reconnect-failed",
|
||||||
|
Sound::ChatSent => "chat-sent",
|
||||||
|
Sound::ChatReceived => "chat-received",
|
||||||
|
Sound::ContactOnline => "contact-online",
|
||||||
|
Sound::ContactOffline => "contact-offline",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user