#![allow(non_snake_case)] use dioxus::prelude::*; use mumble_web2_client::{ network_entrypoint, reqwest, AudioSettings, ChannelId, Command, ConfigSystem, ConfigSystemInterface as _, ConnectTarget, ConnectionState, Platform, PlatformInterface as _, SharedState, State, UserId, UserState, }; use mumble_web2_common::{ProxyOverrides, ServerEntry}; use Command::*; use ConnectionState::*; const ADDRESS_PATTERN: &str = "[A-Za-z0-9.-]+"; fn address_is_valid(addr: &str) -> bool { !addr.is_empty() && !addr.contains(':') } #[derive(Clone, Copy, PartialEq, Eq)] pub enum UserIcon { Normal, Muted, Deafened, Suppressed, None, } impl UserIcon { pub fn icon(user: &UserState) -> UserIcon { if user.deaf || user.self_deaf { UserIcon::Deafened } else if user.mute || user.self_mute { UserIcon::Muted } else if user.suppress { UserIcon::Suppressed } else { UserIcon::Normal } } 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: UserIcon::icon(state), 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 = mumble_web2_client::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] fn OverrideLoginView(overrides: Resource) -> Element { let user_config = use_context::(); let net: Coroutine = use_coroutine_handle(); let state = use_context::(); let version = option_env!("MUMBLE_WEB2_VERSION"); let proxy_url = overrides .read() .as_ref() .and_then(|c| c.proxy_url.clone()) .unwrap_or_default(); let mut username = use_signal(|| { user_config .config_get::("username") .unwrap_or_default() }); let is_connecting = matches!(&*state.status.read(), Connecting); rsx!( div { class: "server-list-page", h1 { "Mumble Web" match version { Some(v) => rsx!(div { class: "login_version", "({v})" }), None => rsx!(), } } div { class: "server-list", div { class: "server-card", img { class: "server-card__icon", src: asset!("assets/earth-14-svgrepo-com.svg"), alt: "Server icon", } div { class: "server-card__info", span { class: "server-card__name", "Server" } span { class: "server-card__address", "{proxy_url}" } } } div { class: "override-username-row", input { class: "override-username-input", r#type: "text", placeholder: "Username", value: "{username.read()}", oninput: move |evt| username.set(evt.value().clone()), } button { class: "server-card__action server-card__action--connect", disabled: is_connecting || username.read().is_empty(), onclick: { let proxy_url = proxy_url.clone(); let user_config = user_config.clone(); move |_| { user_config.config_set("username", &*username.read()); net.send(Connect { target: ConnectTarget::Proxy(proxy_url.clone()), username: username.read().clone(), config: overrides.read().clone().unwrap_or_default(), }); } }, img { src: asset!("assets/arrow-right-svgrepo-com.svg"), alt: "Connect", } } } match &*state.status.read() { Failed(msg) => rsx!( div { class: "login_error", "Failed to connect:" pre { "{msg}" } } ), _ => rsx!(), } } } ) } #[component] pub fn LoginView(overrides: Resource) -> Element { let user_config = use_context::(); let net: Coroutine = use_coroutine_handle(); let state = use_context::(); let mut servers = use_signal(|| { user_config .config_get::>("servers") .unwrap_or_default() }); let mut show_add_modal = use_signal(|| false); let mut editing_index = use_signal(|| None::); let version = option_env!("MUMBLE_WEB2_VERSION"); let is_override_mode = overrides.read().as_ref().is_some_and(|c| !c.any_server); // --- Overrides mode: single preset server, username-only input --- if is_override_mode { return rsx!(OverrideLoginView { overrides }); } // --- Normal mode: editable server list --- rsx!( div { class: "server-list-page", h1 { "Mumble Web" match version { Some(v) => rsx!(div { class: "login_version", "({v})" }), None => rsx!(), } } div { class: "server-list", for (idx, server) in servers.read().iter().enumerate() { { let address = format!("{}:{}", server.address, server.port); let connect_entry = server.clone(); rsx!( div { key: "{idx}", class: "server-card", img { class: "server-card__icon", src: asset!("assets/earth-14-svgrepo-com.svg"), alt: "Server icon", } div { class: "server-card__info", span { class: "server-card__name", "{server.name}" } span { class: "server-card__address", "{address}" } } ServerPingInfo { address: server.address.clone(), port: server.port, } button { class: "server-card__action", onclick: move |_| editing_index.set(Some(idx)), img { src: asset!("assets/edit-3-svgrepo-com.svg"), alt: "Edit", } } button { class: "server-card__action server-card__action--connect", onclick: { let entry = connect_entry.clone(); let user_config = user_config.clone(); move |_| { user_config.config_set("username", &entry.username); net.send(Connect { target: ConnectTarget::Direct { host: entry.address.clone(), port: entry.port, }, username: entry.username.clone(), config: overrides.read().clone().unwrap_or_default(), }); } }, img { src: asset!("assets/arrow-right-svgrepo-com.svg"), alt: "Connect", } } } ) } } } match &*state.status.read() { Failed(msg) => rsx!( div { class: "server-list", div { class: "login_error", "Failed to connect:" pre { "{msg}" } } } ), _ => rsx!(), } button { class: "add-server-btn", onclick: move |_| show_add_modal.set(true), "+ Add Server" } if *show_add_modal.read() { { let user_config = user_config.clone(); rsx!(AddServerModal { on_save: move |entry: ServerEntry| { servers.write().push(entry); user_config.config_set("servers", &*servers.read()); show_add_modal.set(false); }, on_cancel: move |_| show_add_modal.set(false), }) } } if let Some(idx) = *editing_index.read() { if let Some(entry) = servers.read().get(idx).cloned() { { let user_config_save = user_config.clone(); let user_config_del = user_config.clone(); rsx!(EditServerModal { entry, on_save: move |updated: ServerEntry| { servers.write()[idx] = updated; user_config_save.config_set("servers", &*servers.read()); editing_index.set(None); }, on_delete: move |_| { servers.write().remove(idx); user_config_del.config_set("servers", &*servers.read()); editing_index.set(None); }, on_cancel: move |_| editing_index.set(None), }) } } } } ) } #[component] fn ServerPingInfo(address: String, port: u16) -> Element { let ping_result = use_resource(move || { let addr = format!("{}:{}", address.clone(), port); async move { let client = reqwest::Client::new(); Platform::get_status(&client, &addr).await } }); let read = ping_result.read(); match &*read { Some(Ok(status)) => { let users_text = match (status.users, status.max_users) { (Some(u), Some(m)) => format!("{u}/{m}"), (Some(u), None) => format!("{u} online"), _ => String::new(), }; rsx!( div { class: "server-card__ping", if !users_text.is_empty() { span { "{users_text}" } } } ) } Some(Err(_)) => rsx!( div { class: "server-card__ping", span { "offline" } } ), None => rsx!( div { class: "server-card__ping", span { "..." } } ), } } #[component] fn AddServerModal(on_save: EventHandler, on_cancel: EventHandler<()>) -> Element { let user_config = use_context::(); let mut name = use_signal(|| String::new()); let mut address = use_signal(|| String::new()); let mut port = use_signal(|| "64738".to_string()); let mut username = use_signal(|| { user_config .config_get::("username") .unwrap_or_default() }); let mut password = use_signal(|| String::new()); let do_save = move |_| { let port_num: u16 = port.read().parse().unwrap_or(64738); on_save.call(ServerEntry { name: name.read().clone(), address: address.read().clone(), port: port_num, username: username.read().clone(), password: if password.read().is_empty() { None } else { Some(password.read().clone()) }, }); }; rsx! { div { class: "modal-backdrop", onclick: move |_| on_cancel.call(()), } div { class: "modal-container", onclick: move |evt| evt.stop_propagation(), div { class: "modal", h2 { "Add Server" } div { class: "modal-field", label { "Name" } input { r#type: "text", placeholder: "My Mumble Server", value: "{name.read()}", oninput: move |evt| name.set(evt.value().clone()), required: true, } } div { class: "modal-field", label { "Address" } input { r#type: "text", placeholder: "mumble.example.com", pattern: ADDRESS_PATTERN, value: "{address.read()}", oninput: move |evt| address.set(evt.value().clone()), required: true, } div { class: "modal-field__error", "Enter a hostname or IP address only — do not include a port." } } div { class: "modal-field", label { "Port" } input { r#type: "number", placeholder: "64738", value: "{port.read()}", oninput: move |evt| port.set(evt.value().clone()), required: true, } } div { class: "modal-field", label { "Username" } input { r#type: "text", placeholder: "Nickname", value: "{username.read()}", oninput: move |evt| username.set(evt.value().clone()), required: true, } } div { class: "modal-field", label { "Password (optional)" } input { r#type: "password", placeholder: "Password", value: "{password.read()}", oninput: move |evt| password.set(evt.value().clone()), } } div { class: "modal-actions", button { class: "modal-btn", onclick: move |_| on_cancel.call(()), "Cancel" } button { class: "modal-btn modal-btn--primary", disabled: !address_is_valid(&address.read()) || username.read().is_empty(), onclick: do_save, "Save" } } } } } } #[component] fn EditServerModal( entry: ServerEntry, on_save: EventHandler, on_delete: EventHandler<()>, on_cancel: EventHandler<()>, ) -> Element { let mut name = use_signal(|| entry.name.clone()); let mut address = use_signal(|| entry.address.clone()); let mut port = use_signal(|| entry.port.to_string()); let mut username = use_signal(|| entry.username.clone()); let mut password = use_signal(|| entry.password.clone().unwrap_or_default()); let do_save = move |_| { let port_num: u16 = port.read().parse().unwrap_or(64738); on_save.call(ServerEntry { name: name.read().clone(), address: address.read().clone(), port: port_num, username: username.read().clone(), password: if password.read().is_empty() { None } else { Some(password.read().clone()) }, }); }; rsx! { div { class: "modal-backdrop", onclick: move |_| on_cancel.call(()), } div { class: "modal-container", onclick: move |evt| evt.stop_propagation(), div { class: "modal", h2 { "Edit Server" } div { class: "modal-field", label { "Name" } input { r#type: "text", placeholder: "My Mumble Server", value: "{name.read()}", oninput: move |evt| name.set(evt.value().clone()), required: true, } } div { class: "modal-field modal-field--strict", label { "Address" } input { r#type: "text", placeholder: "mumble.example.com", pattern: ADDRESS_PATTERN, value: "{address.read()}", oninput: move |evt| address.set(evt.value().clone()), required: true, } div { class: "modal-field__error", "Enter a hostname or IP address only — do not include a port." } } div { class: "modal-field", label { "Port" } input { r#type: "number", placeholder: "64738", value: "{port.read()}", oninput: move |evt| port.set(evt.value().clone()), required: true, } } div { class: "modal-field", label { "Username" } input { r#type: "text", placeholder: "Nickname", value: "{username.read()}", oninput: move |evt| username.set(evt.value().clone()), required: true, } } div { class: "modal-field", label { "Password (optional)" } input { r#type: "password", placeholder: "Password", value: "{password.read()}", oninput: move |evt| password.set(evt.value().clone()), } } div { class: "modal-actions", button { class: "modal-btn modal-btn--danger", onclick: move |_| on_delete.call(()), "Delete" } span { class: "modal-actions__spacer" } button { class: "modal-btn", onclick: move |_| on_cancel.call(()), "Cancel" } button { class: "modal-btn modal-btn--primary", disabled: !address_is_valid(&address.read()) || username.read().is_empty(), onclick: do_save, "Save" } } } } } } #[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| { 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 }), } ) } pub fn main() { Platform::init_logging(); dioxus::LaunchBuilder::new() .with_cfg(desktop! { dioxus::desktop::Config::new() // Reduce white flash on startup by setting background color and hiding main element .with_background_color((0, 0, 0, 255)) .with_custom_head("".into()) .with_disable_context_menu(cfg!(not(debug_assertions))) .with_window( dioxus::desktop::WindowBuilder::new() .with_title("Mumble Web 2") .with_min_inner_size(dioxus::desktop::LogicalSize::new(600.0, 300.0)) .with_inner_size(dioxus::desktop::LogicalSize::new(900.0, 700.0)) .with_maximized(false), ) }) .launch(app); }