#![allow(non_snake_case)] use dioxus::prelude::*; use mime_guess::Mime; use mumble_web2_common::{ClientConfig, ServerStatus}; use ordermap::OrderSet; use std::collections::HashMap; use crate::imp; pub type ChannelId = u32; pub type UserId = u32; pub enum ConnectionState { Disconnected, Connecting, Connected, Failed(String), } #[derive(Debug)] pub enum Command { Connect { address: String, username: String, config: ClientConfig, }, 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, }, UpdateMicEffects { denoise: bool, }, Disconnect, } use Command::*; use ConnectionState::*; #[derive(Default)] pub struct ChannelState { pub name: String, pub children: OrderSet, pub users: OrderSet, pub parent: Option, } #[derive(Default)] 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 } } } pub struct Chat { pub raw: String, pub dangerous_html: String, pub sender: Option, } #[derive(Default)] pub struct ServerState { pub channels: HashMap, 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: GlobalSignal, pub server: GlobalSignal, } pub static STATE: State = State { status: Signal::global(|| Disconnected), server: Signal::global(|| Default::default()), }; #[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 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 server = STATE.server.read(); let user = server.session.unwrap(); let Some(state) = server.channels.get(&id) else { return rsx!("missing channel {id}"); }; rsx!( details { class: "channel_details", open: true, summary { span { role: "button", ondoubleclick: move |evt| { evt.stop_propagation(); evt.prevent_default(); net.send(EnterChannel { channel: id, user }) }, "{state.name}" } } if state.users.len() + state.children.len() > 0 { 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 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 server = STATE.server.read(); let mut draft = use_signal(|| "".to_string()); let mut do_send = move || { if let Some(user) = STATE.server.read().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(config: Resource) -> Element { let net: Coroutine = use_coroutine_handle(); let status = &STATE.status; let server = STATE.server.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[&channel].name.clone(); let proxy_url = config .read_unchecked() .as_ref() .and_then(|gui_config| gui_config.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" } } } }, }; let denoise = use_signal(|| false); 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 denoise() { true => "toggle_button is_on", false => "toggle_button", }, role: "switch", aria_checked: denoise(), onclick: move |_| { let new_denoise = !denoise(); *denoise.write_unchecked() = new_denoise; net.send(UpdateMicEffects { denoise: new_denoise }) }, match 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(config: Resource) -> Element { let net: Coroutine = use_coroutine_handle(); 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.iter() { if state.parent.is_none() { Channel { id: *id } } } } div { class: "server_chat_box", ChatView {} } div { class: "server_control_box", ControlView { config } } } ) } #[component] pub fn LoginView(config: Resource) -> Element { 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(imp::get_status(&client).await); imp::sleep(std::time::Duration::from_secs_f32(1.0)).await; } }); let mut address_input = use_signal(|| imp::load_server_url()); let address = use_memo(move || { if let Some(addr) = address_input() { addr.clone() } else { config() .and_then(|c| c.proxy_url.clone()) .unwrap_or_default() } }); let previous_username = imp::load_username(); let mut username = use_signal(|| previous_username.unwrap_or(String::new())); let do_connect = move |_| { //let _ = set_default_username(&username.read()); let _ = imp::set_default_username(&username.read()); if config.read().as_ref().is_some_and(|cfg| cfg.any_server) { imp::set_default_server(&address.read()); } net.send(Connect { address: address.read().clone(), username: username.read().clone(), config: config.read().clone().unwrap_or_default(), }) }; 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 config.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} // } // ) } pub fn app() -> Element { static STYLE: Asset = asset!("/assets/main.scss"); use_coroutine(|rx: UnboundedReceiver| super::network_entrypoint(rx)); let config = use_resource(|| async move { match imp::load_config().await { Ok(config) => config, Err(_) => ClientConfig::default(), } }); imp::request_permissions(); 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 { config }), _ => rsx!(LoginView { config }), } ) }