diff --git a/desktop/src-tauri/src/huddle/audio_output.rs b/desktop/src-tauri/src/huddle/audio_output.rs index 383a7e8210..6335100dd4 100644 --- a/desktop/src-tauri/src/huddle/audio_output.rs +++ b/desktop/src-tauri/src/huddle/audio_output.rs @@ -35,7 +35,7 @@ fn list_audio_output_devices_blocking() -> Result, String } /// Set the preferred audio output device by name. Empty string = system default. -/// Takes effect on the next huddle start/join (does not change a live stream). +/// An active huddle moves subsequent playout to the selected route immediately. #[tauri::command] pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Result<(), String> { let mut guard = state @@ -43,7 +43,12 @@ pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Resu .output_device .lock() .map_err(|e| e.to_string())?; - *guard = if name.is_empty() { None } else { Some(name) }; + let selected = if name.is_empty() { None } else { Some(name) }; + *guard = selected.clone(); + state + .huddle_audio + .output_device_changes + .send_replace(selected); Ok(()) } diff --git a/desktop/src-tauri/src/huddle/latency_bench.rs b/desktop/src-tauri/src/huddle/latency_bench.rs index f928ddbce0..2f7787df65 100644 --- a/desktop/src-tauri/src/huddle/latency_bench.rs +++ b/desktop/src-tauri/src/huddle/latency_bench.rs @@ -143,8 +143,8 @@ fn baseline_stt_fake_llm_tts_first_audio() { Arc::clone(&tts_cancel), super::human_floor::HumanFloor::new(), "eve", - None, // default output device - None, // no Tauri app handle + tokio::sync::watch::channel(None).1, // default output device + None, // no Tauri app handle ) .expect("tts pipeline"); eprintln!( @@ -158,7 +158,7 @@ fn baseline_stt_fake_llm_tts_first_audio() { None, None, super::human_floor::HumanFloor::new(), - None, + tokio::sync::watch::channel(None).1, ) .expect("stt pipeline"); // Recognizer loads inside the worker thread; give it time, then verify diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 47d4aeb43d..4254bbc729 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -250,7 +250,7 @@ pub(crate) async fn post_connect_setup( } return Ok(PostConnectOutcome::Stale); } - let (cancel, pcm_tx) = audio_result?; + let (cancel, pcm_tx) = audio_result.map_err(|error| error.to_string())?; hs.audio_ws_cancel = Some(cancel); hs.audio_relay_pcm_tx = Some(pcm_tx); } @@ -349,12 +349,7 @@ pub(crate) async fn maybe_start_stt_pipeline( ptt, manual_mic_unmuted, hs.human_floor.clone(), - state - .huddle_audio - .output_device - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(), + state.huddle_audio.output_device_changes.subscribe(), old, ) }; @@ -447,12 +442,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result Result, ws_tx_for_pongs: Arc>>, - sink_handle: rodio::MixerDeviceSink, + mut sink_handle: rodio::MixerDeviceSink, cancel: CancellationToken, app_handle: Option, initial_peers: Vec<(u8, String, u8)>, @@ -276,6 +276,7 @@ pub(crate) async fn run_playout_recv_loop( remote_stt_pipeline: Arc>>>, agent_pubkeys: Arc>>, human_floor: HumanFloor, + mut output_device_changes: tokio::sync::watch::Receiver>, ) { use rodio::buffer::SamplesBuffer; use std::num::NonZero; @@ -321,6 +322,25 @@ pub(crate) async fn run_playout_recv_loop( tokio::select! { biased; _ = cancel.cancelled() => break, + changed = output_device_changes.changed() => { + if changed.is_err() { + break; + } + let selected = output_device_changes.borrow_and_update().clone(); + match super::audio_output::open_output_sink_by_name(selected.as_deref()) { + Ok(next_sink) => { + sink_handle = next_sink; + let mixer = sink_handle.mixer().clone(); + for slot in peers.values_mut() { + slot.player = rodio::Player::connect_new(&mixer); + slot.recovering_playout = false; + } + } + Err(error) => { + eprintln!("buzz-desktop: live output device switch failed: {error}"); + } + } + } _ = playout_tick.tick() => { // Drain one 10 ms frame from each *active* peer's NetEq into // its Player. NetEq always emits a frame (Expand/silence when diff --git a/desktop/src-tauri/src/huddle/reconnect.rs b/desktop/src-tauri/src/huddle/reconnect.rs index 6996776f1c..3950ba5b64 100644 --- a/desktop/src-tauri/src/huddle/reconnect.rs +++ b/desktop/src-tauri/src/huddle/reconnect.rs @@ -31,9 +31,21 @@ pub async fn reconnect_huddle_audio(state: State<'_, AppState>) -> Result<(), St ) }; - let (cancel, pcm_tx) = - relay_api::connect_audio_relay(&ephemeral_channel_id, parent_channel_id.as_deref(), &state) - .await?; + let (cancel, pcm_tx) = match relay_api::connect_audio_relay( + &ephemeral_channel_id, + parent_channel_id.as_deref(), + &state, + ) + .await + { + Ok(connection) => connection, + Err(error) => { + if error.code() == Some("huddle_relay_draining") { + eprintln!("buzz-desktop: huddle reconnect deferred while relay is draining"); + } + return Err(error.to_string()); + } + }; let mut hs = state.huddle()?; let still_current = !matches!(hs.phase, HuddlePhase::Idle | HuddlePhase::Leaving) diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index d5b2f8f8ed..1cc14f4cd4 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -69,13 +69,67 @@ fn build_audio_auth_event( .map_err(|e| format!("sign: {e}")) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AudioRelayConnectError { + code: Option, + message: String, +} + +impl AudioRelayConnectError { + pub(crate) fn code(&self) -> Option<&str> { + self.code.as_deref() + } + + fn from_relay_payload(value: &serde_json::Value) -> Self { + Self { + code: value["code"].as_str().map(str::to_string), + message: value["message"] + .as_str() + .unwrap_or("unknown relay error") + .to_string(), + } + } +} + +impl From for AudioRelayConnectError { + fn from(message: String) -> Self { + Self { + code: None, + message, + } + } +} + +impl From<&str> for AudioRelayConnectError { + fn from(message: &str) -> Self { + message.to_string().into() + } +} + +impl std::fmt::Display for AudioRelayConnectError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.code.as_deref() { + Some(code) => write!( + formatter, + "audio relay auth error [{code}]: {}", + self.message + ), + None => formatter.write_str(&self.message), + } + } +} + +fn format_audio_relay_error(value: &serde_json::Value) -> AudioRelayConnectError { + AudioRelayConnectError::from_relay_payload(value) +} + async fn connect_authenticated_audio_socket( channel_id: &str, parent_channel_id: Option<&str>, relay_url: &str, keys: &nostr::Keys, auth_tag_json: Option<&str>, -) -> Result<(WsSink, WsReceiver, u8, Vec<(u8, String, u8)>), String> { +) -> Result<(WsSink, WsReceiver, u8, Vec<(u8, String, u8)>), AudioRelayConnectError> { use nostr::JsonUtil; let ws_url = format!("{relay_url}/huddle/{channel_id}/audio"); @@ -153,7 +207,7 @@ async fn connect_authenticated_audio_socket( break Ok((peer_index, peers)); } Some("error") => { - break Err(format!("audio relay auth error: {}", value["message"])); + break Err(format_audio_relay_error(&value)); } _ => continue, } @@ -179,7 +233,7 @@ pub(crate) async fn connect_audio_relay( channel_id: &str, parent_channel_id: Option<&str>, state: &AppState, -) -> Result<(CancellationToken, tokio::sync::mpsc::Sender>), String> { +) -> Result<(CancellationToken, tokio::sync::mpsc::Sender>), AudioRelayConnectError> { let relay_url = crate::relay::relay_ws_url_with_override(state); let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); @@ -212,12 +266,8 @@ pub(crate) async fn connect_audio_relay( let cancel = CancellationToken::new(); let cancel_clone = cancel.clone(); let (pcm_tx, pcm_rx) = tokio::sync::mpsc::channel::>(50); - let output_device_name = state - .huddle_audio - .output_device - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(); + let output_device_changes = state.huddle_audio.output_device_changes.subscribe(); + let output_device_name = output_device_changes.borrow().clone(); tokio::spawn(async move { if let Err(e) = audio_relay_pipeline(AudioRelayPipelineArgs { @@ -234,6 +284,7 @@ pub(crate) async fn connect_audio_relay( agent_pubkeys, human_floor, output_device_name, + output_device_changes, }) .await { @@ -395,7 +446,8 @@ pub(crate) async fn connect_tts_audio_publisher( keys, auth_tag_json, ) - .await?; + .await + .map_err(|error| error.to_string())?; let cancel = CancellationToken::new(); let publisher_cancel = cancel.clone(); @@ -528,6 +580,7 @@ struct AudioRelayPipelineArgs { agent_pubkeys: Arc>>, human_floor: super::human_floor::HumanFloor, output_device_name: Option, + output_device_changes: tokio::sync::watch::Receiver>, } async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String> { @@ -545,6 +598,7 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String agent_pubkeys, human_floor, output_device_name, + output_device_changes, } = args; let mut encoder = opus::Encoder::new(48000, opus::Channels::Mono, opus::Application::Voip) @@ -658,6 +712,7 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String remote_stt_pipeline, agent_pubkeys, human_floor, + output_device_changes, )); // Any pipeline task ending tears down the other two. The encoder never @@ -750,6 +805,33 @@ pub(crate) async fn count_human_members( mod tests { use super::*; + #[test] + fn relay_auth_errors_preserve_stable_codes_for_ui_mapping() { + for (code, message) in [ + ("room_full", "room participant capacity reached"), + ("room_ended", "huddle has ended"), + ("huddle_relay_draining", "relay is draining; reconnect"), + ( + "huddle_owner_unreachable", + "could not reach the huddle owner", + ), + ("unsupported_version", "unsupported audio protocol version"), + ("upgrade_required", "audio protocol upgrade required"), + ] { + let payload = serde_json::json!({ + "type": "error", + "code": code, + "message": message, + }); + let error = format_audio_relay_error(&payload); + assert_eq!(error.code(), Some(code)); + assert_eq!( + error.to_string(), + format!("audio relay auth error [{code}]: {message}") + ); + } + } + #[test] fn audio_send_queue_drops_oldest_frame_when_full() { let queue = AudioSendQueue::default(); diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index c27bf38b64..3d6c35db2b 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -100,7 +100,7 @@ impl SttPipeline { ptt_active: Option>, manual_mic_unmuted: Option>, human_floor: HumanFloor, - output_device: Option, + output_device_changes: tokio::sync::watch::Receiver>, ) -> Result<(Self, tokio_mpsc::Receiver), String> { let (audio_tx, audio_rx) = mpsc::sync_channel::(AUDIO_QUEUE_DEPTH); let (text_tx, text_rx) = tokio_mpsc::channel::(64); @@ -120,7 +120,7 @@ impl SttPipeline { ptt_active_worker, manual_mic_unmuted_worker, human_floor, - output_device, + output_device_changes, ) }) .map_err(|e| format!("failed to spawn stt-worker thread: {e}"))?; @@ -449,7 +449,7 @@ fn stt_worker( ptt_active: Option>, manual_mic_unmuted: Option>, human_floor: HumanFloor, - output_device: Option, + output_device_changes: tokio::sync::watch::Receiver>, ) { // ── 1. Initialise sherpa-onnx recognizer ───────────────────────────────── // @@ -566,7 +566,7 @@ fn stt_worker( manual_gate, &human_floor, local_barge_in_state, - output_device.as_deref(), + output_device_changes.borrow().as_deref(), track_local_floor, ); } diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 3f12f883ba..a1ca712e71 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -200,7 +200,7 @@ impl TtsPipeline { cancel: Arc, human_floor: HumanFloor, voice: &str, - output_device: Option, + output_device_changes: tokio::sync::watch::Receiver>, activity_app: Option, ) -> Result { let (text_tx, text_rx) = mpsc::sync_channel::(TEXT_QUEUE_DEPTH); @@ -255,7 +255,7 @@ impl TtsPipeline { worker_playback_probe, worker_broadcasters, ), - output_device, + output_device_changes, activity_app, startup_tx, ) @@ -318,6 +318,19 @@ fn authorize_or_defer_queued_text( } } +pub(super) fn apply_pending_output_device_change( + output_device_changes: &mut tokio::sync::watch::Receiver>, + mut apply: impl FnMut(Option<&str>) -> Result<(), String>, +) { + if !output_device_changes.has_changed().unwrap_or(false) { + return; + } + let selected = output_device_changes.borrow_and_update().clone(); + if let Err(error) = apply(selected.as_deref()) { + eprintln!("buzz-desktop: live TTS output device switch failed: {error}"); + } +} + #[allow(clippy::too_many_arguments)] fn tts_worker( model_dir: PathBuf, @@ -325,7 +338,7 @@ fn tts_worker( text_rx: mpsc::Receiver, human_floor: HumanFloor, control_state: WorkerControlState, - output_device: Option, + mut output_device_changes: tokio::sync::watch::Receiver>, activity_app: Option, startup_tx: mpsc::SyncSender>, ) { @@ -397,16 +410,17 @@ fn tts_worker( // ── 3. Initialise rodio output device ───────────────────────────────────── use rodio::buffer::SamplesBuffer; - let sink_handle = match super::audio_output::open_output_sink_by_name(output_device.as_deref()) - { - Ok(h) => h, - Err(e) => { - let error = format!("TTS audio output initialization failed: {e}"); - eprintln!("buzz-desktop: tts stage=startup status=failed reason=output_open"); - let _ = startup_tx.send(Err(error)); - return; - } - }; + let initial_output_device = output_device_changes.borrow().clone(); + let mut sink_handle = + match super::audio_output::open_output_sink_by_name(initial_output_device.as_deref()) { + Ok(h) => h, + Err(e) => { + let error = format!("TTS audio output initialization failed: {e}"); + eprintln!("buzz-desktop: tts stage=startup status=failed reason=output_open"); + let _ = startup_tx.send(Err(error)); + return; + } + }; let channels = match NonZero::new(1u16) { Some(c) => c, @@ -505,11 +519,17 @@ fn tts_worker( channels, rate, }; - let append_audio = |prepared: PreparedModelAudio, - route_id: u64, - speaker_pubkey: Option<&str>, - speaker_generation: u64, - floor_epoch: u64| { + let mut append_audio = |prepared: PreparedModelAudio, + route_id: u64, + speaker_pubkey: Option<&str>, + speaker_generation: u64, + floor_epoch: u64| { + apply_pending_output_device_change(&mut output_device_changes, |selected| { + let next_sink = super::audio_output::open_output_sink_by_name(selected)?; + playback.replace_output_mixer(next_sink.mixer()); + sink_handle = next_sink; + Ok(()) + }); let broadcast_samples = speaker_pubkey.map(|_| prepared.buffer.clone()); append_worker_audio( &append_context, diff --git a/desktop/src-tauri/src/huddle/tts_playback.rs b/desktop/src-tauri/src/huddle/tts_playback.rs index 8a90c01899..deac955f73 100644 --- a/desktop/src-tauri/src/huddle/tts_playback.rs +++ b/desktop/src-tauri/src/huddle/tts_playback.rs @@ -122,6 +122,17 @@ impl PlaybackCoordinator { } } + pub(super) fn replace_output_mixer(&self, mixer: &Mixer) { + *self.mixer.lock().unwrap_or_else(PoisonError::into_inner) = Some(mixer.clone()); + let old_player = { + let mut state = self.lock(); + state.first_append = true; + state.output_lease.begin_hangover(Instant::now()); + state.player.replace(Player::connect_new(mixer)) + }; + drop(old_player); + } + fn lock(&self) -> MutexGuard<'_, PlaybackState> { self.state.lock().unwrap_or_else(PoisonError::into_inner) } @@ -422,6 +433,44 @@ mod tests { ) } + #[test] + fn pending_output_watch_change_replaces_live_playback_before_next_append() { + let (playback, _old_source) = coordinator(); + append_second(&playback); + assert!(!playback.empty()); + + let (output_tx, mut output_rx) = tokio::sync::watch::channel(None); + output_tx.send_replace(Some("new route".to_string())); + let channels = NonZero::new(1).expect("nonzero channels"); + let rate = NonZero::new(24_000).expect("nonzero rate"); + let (next_mixer, _next_source) = rodio::mixer::mixer(channels, rate); + crate::huddle::tts::apply_pending_output_device_change(&mut output_rx, |selected| { + assert_eq!(selected, Some("new route")); + playback.replace_output_mixer(&next_mixer); + Ok(()) + }); + + assert!(playback.empty(), "the old-route queue must be dropped"); + append_second(&playback); + assert!(!playback.empty(), "subsequent audio must use the new mixer"); + } + + #[test] + fn replacing_output_mixer_drops_old_route_and_accepts_new_audio() { + let (playback, _old_source) = coordinator(); + append_second(&playback); + assert!(!playback.empty()); + + let channels = NonZero::new(1).expect("nonzero channels"); + let rate = NonZero::new(24_000).expect("nonzero rate"); + let (next_mixer, _next_source) = rodio::mixer::mixer(channels, rate); + playback.replace_output_mixer(&next_mixer); + + assert!(playback.empty()); + append_second(&playback); + assert!(!playback.empty()); + } + #[test] fn floor_authorized_append_does_not_reenter_the_coordinator_lock() { let (playback, _unpulled_source) = coordinator(); diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs index 75cfef26e5..1e96cd6f09 100644 --- a/desktop/src-tauri/src/huddle/tts_settings.rs +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -45,6 +45,8 @@ pub struct HuddleAudioSettingsState { pub tts_transition: tokio::sync::Mutex<()>, /// Selected huddle output device. `None` uses the system default. pub output_device: Mutex>, + /// Live output-route changes for an active huddle playout loop. + pub output_device_changes: tokio::sync::watch::Sender>, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -624,7 +626,7 @@ pub async fn preview_pocket_voice( cancel, super::human_floor::HumanFloor::new(), &voice_name, - output_device, + tokio::sync::watch::channel(output_device).1, None, )?; pipeline.speak("Hello! This is how I’ll read agent responses.".to_string())?; diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index d5a0423cf7..bd811f3db6 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -22,6 +22,7 @@ import type { RelayEvent } from "@/shared/api/types"; import { KIND_HUDDLE_REACTION } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { getStorageItem, setStorageItem } from "@/shared/lib/safeStorage"; import { useDocumentVisible } from "@/shared/lib/useDocumentVisible"; import { Button } from "@/shared/ui/button"; import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; @@ -71,9 +72,14 @@ const HUDDLE_STATE_FALLBACK_INTERVAL_MS = 30_000; const HUDDLE_MODEL_STATUS_INTERVAL_MS = 10_000; const HUDDLE_REACTION_NAME_MAX = 48; const HEADPHONES_HINT_SEEN_STORAGE_KEY = "buzz.huddle.headphones-hint-seen"; +const PTT_HINT_SEEN_STORAGE_KEY = "buzz.huddle.ptt-hint-seen"; function hasSeenHeadphonesHint() { - return window.localStorage.getItem(HEADPHONES_HINT_SEEN_STORAGE_KEY) === "1"; + return getStorageItem(HEADPHONES_HINT_SEEN_STORAGE_KEY) === "1"; +} + +function hasSeenPttHint() { + return getStorageItem(PTT_HINT_SEEN_STORAGE_KEY) === "1"; } function isVisibleHuddleState(state: HuddleState | null) { @@ -188,6 +194,8 @@ export function HuddleBar({ const [headphonesHintDismissed, setHeadphonesHintDismissed] = React.useState( hasSeenHeadphonesHint, ); + const [pttHintDismissed, setPttHintDismissed] = + React.useState(hasSeenPttHint); const [isLeaving, setIsLeaving] = React.useState(false); const [showAddAgent, setShowAddAgent] = React.useState(false); const [agentAddError, setAgentAddError] = React.useState(null); @@ -330,9 +338,13 @@ export function HuddleBar({ const mainHadActiveHuddleRef = React.useRef(false); const dismissHeadphonesHint = React.useCallback(() => { - window.localStorage.setItem(HEADPHONES_HINT_SEEN_STORAGE_KEY, "1"); + setStorageItem(HEADPHONES_HINT_SEEN_STORAGE_KEY, "1"); setHeadphonesHintDismissed(true); }, []); + const dismissPttHint = React.useCallback(() => { + setStorageItem(PTT_HINT_SEEN_STORAGE_KEY, "1"); + setPttHintDismissed(true); + }, []); React.useEffect(() => { onVisibilityChange?.(isHuddleVisible); @@ -660,6 +672,14 @@ export function HuddleBar({
void; isMuted: boolean; onToggleMute: () => void; isPttMode: boolean; @@ -93,6 +95,8 @@ function usePrefersReducedMotion(): boolean { export function MicControls({ compact = false, + showPttHint = false, + onPttHintDismiss, isMuted, onToggleMute, isPttMode, @@ -146,7 +150,7 @@ export function MicControls({ "overflow-hidden border border-sidebar-border/80 bg-transparent text-sidebar-foreground/70 shadow-none", )} > - + - {isPttMode && !micUnavailable && isEffectivelyMuted ? ( + {showPttHint ? ( + + Hold + + {pushToTalkShortcut} + + to talk — click to switch to open mic + + ) : isPttMode && !micUnavailable && isEffectivelyMuted ? ( Click to unmute or hold diff --git a/desktop/src/features/huddle/lib/huddleError.test.mjs b/desktop/src/features/huddle/lib/huddleError.test.mjs index ec61b83467..fcea5ea6e7 100644 --- a/desktop/src/features/huddle/lib/huddleError.test.mjs +++ b/desktop/src/features/huddle/lib/huddleError.test.mjs @@ -44,3 +44,26 @@ test("uses action-specific fallback copy for unknown errors", () => { "Couldn’t start the huddle.", ); }); + +test("maps relay room, drain, owner, and protocol errors to useful copy", () => { + const cases = [ + ["room_full", "This huddle is full."], + ["room_ended", "This huddle has ended."], + ["huddle_relay_draining", "The huddle relay is restarting. Reconnecting…"], + [ + "huddle_owner_unreachable", + "The huddle relay can’t be reached. Try again in a moment.", + ], + ["unsupported_version", "Update Buzz to join this huddle."], + [ + "upgrade_required", + "This huddle uses a newer audio version. Update Buzz, then try again.", + ], + ]; + for (const [code, expected] of cases) { + assert.equal( + formatHuddleActionError(`audio relay error: ${code}`, "join"), + expected, + ); + } +}); diff --git a/desktop/src/features/huddle/lib/huddleError.ts b/desktop/src/features/huddle/lib/huddleError.ts index 7ee47b2133..02b5c258fb 100644 --- a/desktop/src/features/huddle/lib/huddleError.ts +++ b/desktop/src/features/huddle/lib/huddleError.ts @@ -1,7 +1,40 @@ export type HuddleAction = "join" | "start"; -const HUDDLE_AUDIO_UNAVAILABLE_MESSAGE = - "Huddle audio isn’t available on this server. Ask an administrator to turn it on."; +const HUDDLE_ERROR_MESSAGES: ReadonlyArray<{ + codes: readonly string[]; + message: string; +}> = [ + { + codes: [ + "huddle_audio_unavailable", + "huddle audio unavailable in this deployment", + ], + message: + "Huddle audio isn’t available on this server. Ask an administrator to turn it on.", + }, + { codes: ["room_full"], message: "This huddle is full." }, + { + codes: ["room_ended", "channel is archived"], + message: "This huddle has ended.", + }, + { + codes: ["huddle_relay_draining"], + message: "The huddle relay is restarting. Reconnecting…", + }, + { + codes: ["huddle_owner_unreachable"], + message: "The huddle relay can’t be reached. Try again in a moment.", + }, + { + codes: ["unsupported_version"], + message: "Update Buzz to join this huddle.", + }, + { + codes: ["upgrade_required"], + message: + "This huddle uses a newer audio version. Update Buzz, then try again.", + }, +]; function rawErrorMessage(error: unknown): string | null { if (error instanceof Error) { @@ -20,12 +53,10 @@ export function formatHuddleActionError( const message = rawErrorMessage(error)?.trim(); const normalized = message?.toLowerCase(); - if ( - normalized?.includes("huddle_audio_unavailable") || - normalized?.includes("huddle audio unavailable in this deployment") - ) { - return HUDDLE_AUDIO_UNAVAILABLE_MESSAGE; - } + const mapped = HUDDLE_ERROR_MESSAGES.find(({ codes }) => + codes.some((code) => normalized?.includes(code)), + ); + if (mapped) return mapped.message; if (message) { return message;