feat: remember last nickname + sanitize chat input
Remember last nickname: a new serde-default AppConfig.username field is pre-filled into the launch-screen nickname field, and saved when a room is joined or created (i.e. when the name is actually used), so it carries across launches. Sanitize chat: a pure sanitize_chat() drops control characters (ANSI escapes, NUL, stray CR/LF/TAB), collapses whitespace runs to single spaces, trims, and caps length (2000 chars). Applied to our outgoing text on submit AND to incoming peer messages on receive — peer content is untrusted, so the sender's name and text are both sanitized before display; empty-after-sanitize messages are dropped. Unit tests for sanitize_chat (control/whitespace/unicode/empty + length cap) and a config backward-compat assertion for username. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+56
-3
@@ -226,7 +226,8 @@ impl Default for AppState {
|
|||||||
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
name: "Peer".to_string(),
|
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
||||||
|
name: config.username.clone(),
|
||||||
ticket_input: "".to_string(),
|
ticket_input: "".to_string(),
|
||||||
status_message: "Ready to connect".to_string(),
|
status_message: "Ready to connect".to_string(),
|
||||||
self_id: "".to_string(),
|
self_id: "".to_string(),
|
||||||
@@ -333,6 +334,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
||||||
if !state.ticket_input.is_empty() {
|
if !state.ticket_input.is_empty() {
|
||||||
state.status_message = "Joining room...".to_string();
|
state.status_message = "Joining room...".to_string();
|
||||||
|
// Remember this nickname for next launch.
|
||||||
|
state.config.username = state.name.clone();
|
||||||
|
state.config.save();
|
||||||
// Core releases any standalone mic monitor on join.
|
// Core releases any standalone mic monitor on join.
|
||||||
state.mic_test_active = false;
|
state.mic_test_active = false;
|
||||||
let _ = state.controller.send(CoreCommand::Join {
|
let _ = state.controller.send(CoreCommand::Join {
|
||||||
@@ -348,6 +352,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
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());
|
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
||||||
state.status_message = "Creating room...".to_string();
|
state.status_message = "Creating room...".to_string();
|
||||||
|
// Remember this nickname for next launch.
|
||||||
|
state.config.username = state.name.clone();
|
||||||
|
state.config.save();
|
||||||
// Core releases any standalone mic monitor on join.
|
// Core releases any standalone mic monitor on join.
|
||||||
state.mic_test_active = false;
|
state.mic_test_active = false;
|
||||||
let _ = state.controller.send(CoreCommand::Join {
|
let _ = state.controller.send(CoreCommand::Join {
|
||||||
@@ -457,7 +464,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.status_message = format!("Saved recording → {path}");
|
state.status_message = format!("Saved recording → {path}");
|
||||||
}
|
}
|
||||||
UiEvent::ChatMessage { name, text } => {
|
UiEvent::ChatMessage { name, text } => {
|
||||||
push_chat(&mut state.chat_messages, ChatEntry { name, text, mine: false });
|
// Incoming peer content is untrusted — sanitize name + text.
|
||||||
|
let text = sanitize_chat(&text);
|
||||||
|
if !text.is_empty() {
|
||||||
|
let name = sanitize_chat(&name);
|
||||||
|
push_chat(&mut state.chat_messages, ChatEntry { name, text, mine: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
UiEvent::Error(err) => {
|
UiEvent::Error(err) => {
|
||||||
state.status_message = format!("Error: {}", err);
|
state.status_message = format!("Error: {}", err);
|
||||||
@@ -580,7 +592,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppMessage::ChatSubmit => {
|
AppMessage::ChatSubmit => {
|
||||||
let text = state.chat_input.trim().to_string();
|
let text = sanitize_chat(&state.chat_input);
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
// Local echo (gossip suppresses our own author, so it won't come back).
|
// Local echo (gossip suppresses our own author, so it won't come back).
|
||||||
push_chat(&mut state.chat_messages, ChatEntry {
|
push_chat(&mut state.chat_messages, ChatEntry {
|
||||||
@@ -673,6 +685,24 @@ fn format_duration(total_secs: u64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Max characters kept for a single chat message after sanitizing.
|
||||||
|
const CHAT_MSG_MAX_CHARS: usize = 2000;
|
||||||
|
|
||||||
|
/// Sanitize a chat string for display, applied to BOTH our outgoing text and
|
||||||
|
/// incoming text from peers (peer input is untrusted — a buggy/malicious sender
|
||||||
|
/// could include control characters or an enormous payload). Drops control
|
||||||
|
/// characters (ANSI escapes, NUL, stray CR/LF/TAB), collapses any whitespace run
|
||||||
|
/// to a single space, trims the ends, and caps the length. Returns "" for input
|
||||||
|
/// that is empty after cleaning (the caller skips empty messages).
|
||||||
|
fn sanitize_chat(input: &str) -> String {
|
||||||
|
let no_control: String = input
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_control() { ' ' } else { c })
|
||||||
|
.collect();
|
||||||
|
let collapsed = no_control.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
collapsed.chars().take(CHAT_MSG_MAX_CHARS).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Append a chat line, trimming the oldest once history exceeds the cap so a long
|
/// Append a chat line, trimming the oldest once history exceeds the cap so a long
|
||||||
/// call can't grow the buffer without bound.
|
/// call can't grow the buffer without bound.
|
||||||
fn push_chat(messages: &mut Vec<ChatEntry>, entry: ChatEntry) {
|
fn push_chat(messages: &mut Vec<ChatEntry>, entry: ChatEntry) {
|
||||||
@@ -1692,6 +1722,29 @@ mod tests {
|
|||||||
assert_eq!(clamp_chat_height(window_h, window_h), window_h - 300.0);
|
assert_eq!(clamp_chat_height(window_h, window_h), window_h - 300.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_chat_strips_control_chars_and_collapses_whitespace() {
|
||||||
|
use super::sanitize_chat;
|
||||||
|
// Plain text is unchanged.
|
||||||
|
assert_eq!(sanitize_chat("hello world"), "hello world");
|
||||||
|
// Leading/trailing whitespace trimmed; interior runs collapsed.
|
||||||
|
assert_eq!(sanitize_chat(" hi there "), "hi there");
|
||||||
|
// Control chars (NUL, CR, LF, TAB, ANSI ESC) become spaces, then collapse.
|
||||||
|
// The ESC of an ANSI sequence is stripped; the printable "[31m" remains inert text.
|
||||||
|
assert_eq!(sanitize_chat("a\u{0}b\r\nc\td\u{1b}[31m"), "a b c d [31m");
|
||||||
|
// An all-control / all-whitespace message sanitizes to empty.
|
||||||
|
assert_eq!(sanitize_chat("\u{0}\r\n\t "), "");
|
||||||
|
// Unicode/emoji text is preserved.
|
||||||
|
assert_eq!(sanitize_chat("héllo 🎙 世界"), "héllo 🎙 世界");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_chat_caps_length() {
|
||||||
|
use super::{sanitize_chat, CHAT_MSG_MAX_CHARS};
|
||||||
|
let long = "x".repeat(CHAT_MSG_MAX_CHARS + 500);
|
||||||
|
assert_eq!(sanitize_chat(&long).chars().count(), CHAT_MSG_MAX_CHARS);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn divider_clamps_are_finite_on_a_tiny_window() {
|
fn divider_clamps_are_finite_on_a_tiny_window() {
|
||||||
// A window smaller than the reserves must not produce NaN/inverted ranges.
|
// A window smaller than the reserves must not produce NaN/inverted ranges.
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ fn default_volume() -> f32 {
|
|||||||
1.0
|
1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_username() -> String {
|
||||||
|
"Peer".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_participants_width() -> f32 {
|
fn default_participants_width() -> f32 {
|
||||||
540.0
|
540.0
|
||||||
}
|
}
|
||||||
@@ -54,6 +58,9 @@ fn default_chat_height() -> f32 {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
|
/// Last nickname used to join/create a room; pre-filled on the launch screen.
|
||||||
|
#[serde(default = "default_username")]
|
||||||
|
pub username: String,
|
||||||
pub input_device: String,
|
pub input_device: String,
|
||||||
pub output_device: String,
|
pub output_device: String,
|
||||||
pub noise_gate_threshold: f32,
|
pub noise_gate_threshold: f32,
|
||||||
@@ -98,6 +105,7 @@ pub struct AppConfig {
|
|||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
username: default_username(),
|
||||||
input_device: "".to_string(),
|
input_device: "".to_string(),
|
||||||
output_device: "".to_string(),
|
output_device: "".to_string(),
|
||||||
noise_gate_threshold: 0.01,
|
noise_gate_threshold: 0.01,
|
||||||
@@ -176,6 +184,8 @@ mod tests {
|
|||||||
// Configs predating the draggable dividers must load the default sizes.
|
// Configs predating the draggable dividers must load the default sizes.
|
||||||
assert_eq!(deserialized.participants_width, 540.0);
|
assert_eq!(deserialized.participants_width, 540.0);
|
||||||
assert_eq!(deserialized.chat_height, 180.0);
|
assert_eq!(deserialized.chat_height, 180.0);
|
||||||
|
// Configs predating the remembered username load the default nickname.
|
||||||
|
assert_eq!(deserialized.username, "Peer");
|
||||||
assert!(deserialized.custom_sound_self_join.is_none());
|
assert!(deserialized.custom_sound_self_join.is_none());
|
||||||
assert!(deserialized.custom_sound_peer_join.is_none());
|
assert!(deserialized.custom_sound_peer_join.is_none());
|
||||||
assert!(deserialized.custom_sound_peer_leave.is_none());
|
assert!(deserialized.custom_sound_peer_leave.is_none());
|
||||||
|
|||||||
Reference in New Issue
Block a user