#![allow(non_snake_case)] use dioxus::prelude::*; use mime_guess::Mime; use mumble_web2_common::{ProxyOverrides, ServerStatus}; use ordermap::OrderSet; use std::collections::{HashMap, HashSet}; use std::{fmt, sync::Arc}; use crate::imp::{ConfigSystem, ConfigSystemInterface as _, Platform, PlatformInterface as _}; pub type ChannelId = u32; pub type UserId = u32; #[derive(Debug)] pub enum ConnectionState { Disconnected, Connecting, Connected, Failed(String), } #[derive(Debug, Clone)] pub struct AudioSettings { pub denoise: bool, } #[derive(Debug)] pub enum Command { Connect { address: String, username: String, config: ProxyOverrides, }, SendChat { markdown: String, channels: Vec, }, SendFile { bytes: Vec, name: String, mime: Option, channels: Vec, }, SetMute { mute: bool, }, SetDeaf { deaf: bool, }, EnterChannel { channel: ChannelId, user: UserId, }, UpdateAudioSettings(AudioSettings), Disconnect, } use Command::*; use ConnectionState::*; #[derive(Default, Debug)] pub struct UserState { pub name: String, pub channel: ChannelId, pub deaf: bool, pub mute: bool, pub suppress: bool, pub self_deaf: bool, pub self_mute: bool, } impl UserState { pub fn icon(&self) -> UserIcon { if self.deaf || self.self_deaf { UserIcon::Deafened } else if self.mute || self.self_mute { UserIcon::Muted } else if self.suppress { UserIcon::Suppressed } else { UserIcon::Normal } } } #[derive(Debug)] pub struct Chat { pub raw: String, pub dangerous_html: String, pub sender: Option, } #[derive(Default, Debug)] pub struct ChannelState { pub name: String, pub children: OrderSet, pub users: OrderSet, pub parent: Option, pub position: i32, } impl ChannelState { pub fn update_from_channel_state( &mut self, channel_state: &mumble_protocol::control::msgs::ChannelState, ) { if channel_state.has_position() { self.position = channel_state.get_position(); } if channel_state.has_parent() { self.parent = Some(channel_state.get_parent()); } if channel_state.has_name() { self.name = channel_state.get_name().to_string(); } } } #[derive(Default, Debug)] pub struct ChannelsState { pub channels: HashMap, } impl ChannelsState { pub fn update_from_channel_state( &mut self, channel_state: &mumble_protocol::control::msgs::ChannelState, ) { self.channels .entry(channel_state.get_channel_id()) .or_default() .update_from_channel_state(channel_state); self.update_channel_parents(); } pub fn update_from_channel_remove( &mut self, channel_remove: &mumble_protocol::control::msgs::ChannelRemove, ) { self.channels.remove(&channel_remove.get_channel_id()); self.update_channel_parents(); } pub fn update_channel_parents(&mut self) { // Zero out existing children for state in self.channels.values_mut() { state.children.clear(); } let mut to_sort: Vec<(ChannelId, Option, i32, String)> = Vec::new(); for (id, state) in self.channels.iter() { // Handle channels with no parent (the root channel) let Some(parent_id) = state.parent else { to_sort.push((*id, None, 0, state.name.clone())); continue; }; // If a channel has a parent that we haven't gotten a channel // state packet for, ignore it if !self.channels.contains_key(&parent_id) { continue; } to_sort.push((*id, Some(parent_id), state.position, state.name.clone())); } let pos_name: HashMap = self .channels .iter() .map(|(&id, state)| (id, (state.position, state.name.clone()))) .collect(); let mut updated: HashSet = HashSet::new(); while updated.len() < to_sort.len() { for &(id, ref parent_id, position, ref name) in &to_sort { let Some(parent_id) = parent_id else { updated.insert(id); continue; }; if updated.contains(&id) || !updated.contains(&parent_id) { continue; } // Unwrap should never fail here since we pre filter let parent = self.channels.get_mut(&parent_id).unwrap(); let mut insert_index = parent.children.len(); for (i, &child) in parent.children.iter().enumerate() { let (p, ref n) = pos_name[&child]; if (position == p && name < n) || p > position { insert_index = i; break; } } parent.children.insert_before(insert_index, id); updated.insert(id); } } } } #[derive(Default, Debug)] pub struct ServerState { pub channels_state: ChannelsState, pub users: HashMap, pub chat: Vec, pub session: Option, } impl ServerState { pub fn this_user(&self) -> Option<&UserState> { self.users.get(&self.session?) } } pub struct State { pub status: Signal, pub server: Signal, pub audio: Signal, } impl fmt::Debug for State { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("State") .field("status", &self.status.read()) .field("server", &self.server.read()) .finish() } } pub type SharedState = Arc; #[derive(Clone, Copy, PartialEq, Eq)] pub enum UserIcon { Normal, Muted, Deafened, Suppressed, None, } impl UserIcon { pub fn url(self) -> Option { // speaker from https://www.svgrepo.com/collection/ikono-bold-line-icons/ // mic from https://www.svgrepo.com/collection/hashicorp-line-interface-icons/ use UserIcon::*; Some(match self { Normal => asset!("assets/mic-svgrepo-com.svg"), Muted | Suppressed => asset!("assets/mic-off-svgrepo-com.svg"), Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"), None => return Option::None, }) } } #[component] pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element { let color = match icon { UserIcon::Normal => "var(--accent-normal)", UserIcon::Muted => "var(--accent-muted)", UserIcon::Suppressed | UserIcon::Deafened => "var(--accent-deafened)", UserIcon::None => "var(--accent-normal)", }; rsx!( div { class: match isself { true => "userpil is_self", false => "userpil" }, style: "background-color: {color}", { icon.url().map(|url| rsx!(img { src: url })) } "\u{00A0}{name}\u{00A0}" } ) } #[component] pub fn User(id: UserId) -> Element { let state = use_context::(); let server = state.server.read(); match server.users.get(&id) { Some(state) => rsx!(UserPill { name: state.name.clone(), icon: state.icon(), isself: server.session.unwrap() == id, }), None => rsx!(UserPill { name: format!("unknown user ({id})"), icon: UserIcon::None, isself: false, }), } } #[component] pub fn Channel(id: ChannelId) -> Element { let net: Coroutine = use_coroutine_handle(); let state = use_context::(); let server = state.server.read(); let user = server.session.unwrap(); let Some(state) = server.channels_state.channels.get(&id) else { return rsx!("missing channel {id}"); }; let mut open = use_signal(|| true); let has_children = !state.users.is_empty() || !state.children.is_empty(); rsx!( div { class: "channel_details", div { class: "channel_header", // Arrow: only toggles open if has_children { span { class: "channel_arrow", onclick: move |evt| { evt.stop_propagation(); evt.prevent_default(); let mut w = open.write(); *w = !*w; }, if *open.read() { "▾" } else { "▸" } } } else { span { class: "channel_arrow channel_arrow--placeholder", " " } } // Clickable row area (everything except the arrow) div { class: "channel_row_click", ondblclick: move |evt| { evt.stop_propagation(); evt.prevent_default(); net.send(EnterChannel { channel: id, user }) }, // remove dblclick from the inner span span { class: "channel_title", "{state.name}" } // if you add icons/badges later, put them here too } } if *open.read() && has_children { div { class: "channel_children", for id in state.users.iter() { User { id: *id } } for child in state.children.iter() { Channel { id: *child } } } } } ) } #[cfg(any(feature = "desktop", feature = "web"))] pub fn pick_and_send_file(net: &Coroutine) { let state = use_context::(); let channels = if let Some(user) = state.server.read().this_user() { vec![user.channel] } else { return; }; let dialog = rfd::AsyncFileDialog::new().pick_file(); let sender = net.tx(); spawn(async move { let Some(handle) = dialog.await else { return }; let name = handle.file_name(); let bytes = handle.read().await; let mime = mime_guess::from_path(&name).first(); let _ = sender.unbounded_send(SendFile { bytes, name, mime, channels, }); }); } #[cfg(not(any(feature = "desktop", feature = "web")))] pub fn pick_and_send_file(net: &Coroutine) {} #[component] pub fn ChatView() -> Element { let net: Coroutine = use_coroutine_handle(); let state = use_context::(); let server = state.server.read(); let mut draft = use_signal(|| "".to_string()); let mut do_send = move || { let state = use_context::(); let server = state.server.read(); if let Some(user) = server.this_user() { net.send(SendChat { markdown: draft.write().split_off(0), channels: vec![user.channel], }); } }; rsx!( div { class: "chat_panel", div { class: "chat_history", for chat in server.chat.iter() { div { class: "chat_message", if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) { UserPill { name: sender.name.clone(), icon: UserIcon::None, isself: false, } } span { dangerous_inner_html: "{chat.dangerous_html}", } } } } div { class: "chat_box_wrapper", div { class: "chat_box", input { placeholder: "say something", value: "{draft.read()}", oninput: move |evt| draft.set(evt.value().clone()), onkeypress: move |evt: Event| { if evt.code() == Code::Enter && evt.modifiers().is_empty() { do_send(); } } } div { span { onclick: move |_| pick_and_send_file(&net), class: "material-symbols-outlined", style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;", "attach_file", } } div { span { onclick: move |_| do_send(), class: "material-symbols-outlined", style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;", "send", } } } //button { // onclick: move |_| do_send(), // "Send" //} } } ) } #[component] pub fn ControlView(overrides: Resource) -> Element { let net: Coroutine = use_coroutine_handle(); let state = use_context::(); let status = &state.status; let server = state.server.read(); let audio = state.audio.read(); let Some(&UserState { deaf, self_deaf, mute, suppress, self_mute, ref name, channel, .. }) = server.this_user() else { return rsx!(); }; let current_channel_name = server.channels_state.channels[&channel].name.clone(); let proxy_url = overrides .read_unchecked() .as_ref() .and_then(|overrides| overrides.proxy_url.clone()); let connecting_color = "yellow"; let connected_color = "oklch(0.55 0.1184 141.35)"; let disconnected_color = "gray"; let failed_color = "red"; let connection_status = match &*status.read() { Connecting => rsx! { div { class: "connection_status", style: "color: {connecting_color};", div { span { class: "material-symbols-outlined", "signal_cellular_alt_2_bar" } span { class: "status_text", " Connecting" } } } }, Connected => rsx! { div { class: "connection_status", div { style: "color: {connected_color};", span { class: "material-symbols-outlined", "signal_cellular_alt" } span { class: "status_text", " Connected" } } div { class: "channel_text", span { "{current_channel_name}" } } } }, Disconnected => rsx! { div { class: "connection_status", style: "color: {disconnected_color};", div { span { class: "material-symbols-outlined", "signal_disconnected" } span { class: "status_text", " Disconnected" } } } }, Failed(_) => rsx! { div { class: "connection_status", style: "color: {failed_color};", div { span { class: "material-symbols-outlined", "error" } span { class: "status_text", " Failed" } } } }, }; rsx!( // Server control div { class: "button_row", div { {connection_status} } span { class: "spacer" } button { class: "toggle_button", onclick: move |_| net.send(Disconnect), span { class: "material-symbols-outlined", "signal_disconnected" } } } hr { style: "width: 100%;" } // User control div { class: "button_row", button { class: "user_edit_button", span { class: "material-symbols-outlined", style: "color: oklch(0.65 0.2245 28.06);", "person_edit" } } div { class: "user_info", div { span { class: "user_name", "{name}" } } div { span { class: "user_data", "some data" } } } span { class: "spacer" } button { class: match audio.denoise { true => "toggle_button is_on", false => "toggle_button", }, role: "switch", aria_checked: audio.denoise, onclick: move |_| { let state = use_context::(); let mut audio = state.audio.read().clone(); audio.denoise = !audio.denoise; let denoise = audio.denoise; *state.audio.write_unchecked() = audio; net.send(UpdateAudioSettings(AudioSettings { denoise: denoise })); let user_config = use_context::(); user_config.config_set::("denoise", &denoise); }, match audio.denoise { true => rsx!(span { class: "material-symbols-outlined", "cadence"}), false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}), } } button { class: match mute || suppress || self_mute { true => "toggle_button is_on", false => "toggle_button", }, role: "switch", aria_checked: mute || suppress || self_mute, disabled: mute || suppress, onclick: move |_| net.send(SetMute { mute: !self_mute }), match mute || suppress || self_mute { true => rsx!(span { class: "material-symbols-outlined", "mic_off"}), false => rsx!(span { class: "material-symbols-outlined", "mic"}), } } button { class: match deaf || self_deaf { true => "toggle_button in_on", false => "toggle_button", }, role: "switch", aria_checked: deaf || self_deaf, disabled: deaf, onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }), match deaf || self_deaf { true => rsx!(span { class: "material-symbols-outlined", "volume_off"}), false => rsx!(span { class: "material-symbols-outlined", "volume_up"}), } } } ) } #[component] pub fn ServerView(overrides: Resource) -> Element { let net: Coroutine = use_coroutine_handle(); let state = use_context::(); let server = state.server.read(); let Some(&UserState { deaf, self_deaf, mute, self_mute, .. }) = server.this_user() else { return rsx!(); }; rsx!( div { class: "server_grid", div { class: "server_channel_box", for (id, state) in server.channels_state.channels.iter() { if state.parent.is_none() { Channel { id: *id } } } } div { class: "server_chat_box", ChatView {} } div { class: "server_control_box", ControlView { overrides } } } ) } #[component] pub fn LoginView(overrides: Resource) -> Element { let user_config = use_context::(); let net: Coroutine = use_coroutine_handle(); let last_status = use_signal(|| None::>); use_resource(move || async move { let client = reqwest::Client::new(); loop { *last_status.write_unchecked() = Some(Platform::get_status(&client).await); Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await; } }); let mut address_input = use_signal(|| user_config.config_get::("server_url")); let address = use_memo(move || { if let Some(addr) = address_input() { addr.clone() } else { overrides() .and_then(|c| c.proxy_url.clone()) .unwrap_or_default() } }); let mut username = use_signal(|| { user_config .config_get::("username") .unwrap_or(String::new()) }); let do_connect = move |_| { let _ = user_config.config_set::("username", &username.read()); if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) { user_config.config_set::("server_url", &address.read()); } net.send(Connect { address: address.read().clone(), username: username.read().clone(), config: overrides.read().clone().unwrap_or_default(), }) }; let state = use_context::(); let status = &state.status; let bottom = match &*status.read() { Disconnected => rsx! { button { class: "login_bttn", onclick: do_connect.clone(), "Connect" } }, Connecting => rsx! { div { class: "login_bttn", "Connecting..." } }, Failed(msg) => rsx!( button { class: "login_bttn", onclick: do_connect.clone(), "Reconnect" } div { class: "login_error", "Failed to connect:" pre { "{msg}" } } ), Connected => unreachable!(), }; let version = option_env!("MUMBLE_WEB2_VERSION"); rsx!( div { class: "login", h1 { "Mumble Web" match version { Some(v) => rsx!(" " span { class: "login_version", "({v})" }), None => rsx!(), } } if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) { div { label { for: "address-entry", "Server Address:" } input { id: "address-entry", placeholder: "address", value: "{address.read()}", autofocus: "true", oninput: move |evt| address_input.set(Some(evt.value().clone())), } } } div { label { for: "username-entry", "Username:" //style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;", } input { id: "username-entry", placeholder: "username", value: "{username.read()}", autofocus: "true", oninput: move |evt| username.set(evt.value().clone()), } } div { match &*last_status.read() { None => rsx!(div { class: "login_status", span {"···"} }), Some(Ok(ServerStatus { success: false, .. })) => rsx!(div { class: "login_status is_error", span { "Could not reach server" } }), Some(Ok(status)) => rsx!(div { class: "login_status", if let (Some(users), Some(max_users)) = (status.users, status.max_users) { span {"{users}/{max_users} Online"} } else { span {"Unknown Online"} } span {"-"} if let Some((maj, min, pat)) = status.version { span {"Version: {maj}.{min}.{pat}"} } else { span {"Unknown Version"} } }), Some(Err(_)) => rsx!(div { class: "login_status is_error", span { "Could not reach proxy server" } }), } div { {bottom} } } } ) // rsx!( // div { // class: "{login_box}", // h1 { // "Mumble Web" // } // input { // placeholder: "username", // value: "{username.read()}", // autofocus: "true", // oninput: move |evt| username.set(evt.value().clone()), // } // input { // placeholder: "server address", // value: "{address.read()}", // autofocus: "true", // oninput: move |evt| address_input.set(Some(evt.value().clone())), // } // {bottom} // } // ) } #[component] pub fn app() -> Element { static STYLE: Asset = asset!("/assets/main.scss"); use_effect(|| { Platform::request_permissions(); }); let user_config = use_root_context(|| ConfigSystem::new().unwrap()); let state = use_root_context(|| { SharedState::new(State { status: Signal::new(Disconnected), server: Signal::new(Default::default()), audio: Signal::new(AudioSettings { denoise: user_config.config_get::("denoise").unwrap_or(true), }), }) }); let network_state = state.clone(); use_coroutine(move |rx: UnboundedReceiver| { super::network_entrypoint(rx, network_state.clone()) }); let overrides = use_resource(|| async move { match Platform::load_proxy_overrides().await { Ok(overrides) => overrides, Err(_) => ProxyOverrides::default(), } }); rsx!( document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" } document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" } document::Link{ rel: "stylesheet", href: STYLE } match *state.status.read() { Connected => rsx!(ServerView { overrides }), _ => rsx!(LoginView { overrides }), } ) }