#![allow(non_snake_case)] use base64::{display::Base64Display, prelude::BASE64_URL_SAFE}; use dioxus::prelude::*; use mime_guess::Mime; use mumble_web2_common::ClientConfig; use ordermap::OrderSet; use sir::{css, global_css}; use std::collections::HashMap; use tracing::error; 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 self_deaf: bool, pub self_mute: bool, } impl UserState { pub fn icon(&self) -> UserIcon { match (self.mute || self.self_mute, self.deaf || self.self_deaf) { (false, false) => UserIcon::Normal, (true, false) => UserIcon::Muted, (_, true) => UserIcon::Deafened, } } } 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, 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 => 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 pill = css!( " border-radius: 100px; padding: 4px 8px; width: fit-content; img { height: 1em; vertical-align: text-bottom; } " ); let pill_self = css!( " font-weight: bolder; " ); let color = match icon { UserIcon::Normal => "var(--accent-normal)", UserIcon::Muted => "var(--accent-muted)", UserIcon::Deafened => "var(--accent-deafened)", UserIcon::None => "var(--accent-normal)", }; rsx!( div { class: match isself { true => format!("{pill} {pill_self}"), false => format!("{pill}") }, 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}"); }; let channel_details = css!( " flex: 0 0 100%; summary { cursor: pointer; } summary:focus-visible { outline: none; } " ); let channel_children = css!( " border-left: solid var(--line-color) var(--line-width); display: flex; flex-direction: row; flex-wrap: wrap; gap: 8px; margin-left: 5px; padding-left: 11px; padding-top: 4px; " ); 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 } } } } } ) } 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, }); }); } #[component] pub fn ChatView() -> Element { let net: Coroutine = use_coroutine_handle(); let server = STATE.server.read(); let mut draft = use_signal(|| "".to_string()); let chat_panel = css!( " display: flex; flex-direction: column; " ); let chat_history = css!( " overflow-y: auto; flex: 1 0 0; " ); let chat_message = css!( " display: flex; flex-direction: row; margin: 16px; gap: 8px; align-items: center; " ); let chat_box_wrapper = css!( " padding: 16px; border-top: solid var(--line-color) var(--line-width); " ); let chat_box = css!( " display: flex; flex-direction: row; gap: 16px; background-color: var(--light-bg-color); padding-top: 16px; padding-bottom: 16px; padding-left: 8px; padding-right: 16px; border-radius: 8px; input { color: white; background-color: var(--light-bg-color); font-size: larger; flex-grow: 1; border: none; } input:focus { outline: none; } " ); 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" //} } } ) } // true => rsx!(span { class: "material-symbols-outlined", style: "{button_style}", "mic_off"}), //Connecting => rsx! { // div { // class: "{connecting_status}", // span { // class: "material-symbols-outlined", // style: "vertical-align: middle; font-size: 30px;", // "signal_cellular_alt_2_bar" // } // span { // style: "width: 5px; display: inline-block;" // } // span { // style: "vertical-align: middle; font-size: 30px;", // "Connecting" // } // } //}, #[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, self_mute, ref name, channel, .. }) = server.this_user() else { return rsx!(); }; let current_channel_name = server.channels[&channel].name.clone(); let Some(proxy_url) = config .read_unchecked() .as_ref() .and_then(|gui_config| gui_config.proxy_url.clone()) else { return rsx!(); }; let button_row = css!( r#" display: flex; gap: 10px; "# ); let spacer = css!( r#" flex-grow: 1; "# ); let toggle_button = css!( r#" padding: 8px; height: 100%; aspect-ratio: 1 / 1; background-color: unset; border: solid rgb(255 255 255 / 0.1) 3px; border-radius: 10px; color: rgb(255 255 255 / 50%); transition: all 0.5s ease-in-out; "# ); let toggle_button_on = css!( r#" padding: 8px; height: 100%; aspect-ratio: 1 / 1; background-color: oklch(0.5 0.1381 21.71 / 20.12%); border: solid rgb(255 255 255 / 0) 3px; border-radius: 10px; color: oklch(0.53 0.1505 21.71 / 89.38%); transition: all 0.25s ease-in-out; "# ); let button_style = r#" font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; "#; let connecting_status = css!( r#" color: yellow; "# ); let connected_status = css!( r#" color: oklch(0.55 0.1184 141.35); "# ); let disconnected_status = css!( r#" color: gray; "# ); let failed_status = css!( r#" color: red; "# ); let connection_info = css!( r#" color: gray; "# ); let user_edit_button = css!( r#" background-color: oklch(0.53 0.1431 264.18); border-radius: 50%; aspect-ratio: 1 / 1; "# ); let connection_status = match &*status.read() { Connecting => rsx! { div { class: "{connecting_status}", span { class: "material-symbols-outlined", style: "vertical-align: middle; font-size: 30px;", "signal_cellular_alt_2_bar" } span { style: "width: 5px; display: inline-block;" } span { style: "vertical-align: middle; font-size: 30px;", "Connecting" } } }, Connected => rsx! { div { div { class: "{connected_status}", span { class: "material-symbols-outlined", style: "vertical-align: middle; font-size: 30px;", "signal_cellular_alt" } span { style: "width: 5px; display: inline-block;" } span { style: "vertical-align: middle; font-size: 25px;", "Connected" } } div { class: "{connection_info}", span { style: "width: 3px; display: inline-block;"} span { "{current_channel_name}" } span { " — " } span { "{proxy_url}" } } } }, Disconnected => rsx! { div { class: "{disconnected_status}", span { class: "material-symbols-outlined", style: "vertical-align: middle;", "signal_disconnected" } span { style: "width: 5px; display: inline-block;" } span { style: "vertical-align: middle;", "Disconnected" } } }, Failed(_) => rsx! { div { class: "{failed_status}", span { class: "material-symbols-outlined", style: "vertical-align: middle;", "error" } span { style: "width: 5px; display: inline-block;" } span { style: "vertical-align: middle;", "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", style: "{button_style}", "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); font-size: 45px; font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;", "person_edit" } } div { div { span { style: "font-size: 25px;", "{name}" } } div { span { style: "font-size: 20px; color: gray;", "some data" } } } span { class: "{spacer}" } button { class: match denoise() { true => toggle_button_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", style: "{button_style}", "cadence"}), false => rsx!(span { class: "material-symbols-outlined", style: "{button_style}", "graphic_eq"}), } } button { class: match mute || self_mute { true => toggle_button_on, false => toggle_button, }, role: "switch", aria_checked: mute || self_mute, disabled: mute, onclick: move |_| net.send(SetMute { mute: !self_mute }), match mute || self_mute { true => rsx!(span { class: "material-symbols-outlined", style: "{button_style}", "mic_off"}), false => rsx!(span { class: "material-symbols-outlined", style: "{button_style}", "mic"}), } } button { class: match deaf || self_deaf { true => toggle_button_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", style: "{button_style}", "volume_off"}), false => rsx!(span { class: "material-symbols-outlined", style: "{button_style}", "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!(); }; let grid = css!( r#" display: grid; height: 100%; background-color: var(--bg-color); grid-template-rows: 1fr auto; grid-template-columns: 1fr 2fr; grid-template-areas: "tree chat" "control chat"; @media screen and (max-width: 720px) { grid-template-rows: auto 1fr 1fr; grid-template-columns: 1fr; grid-template-areas: "tree" "control" "chat"; } "# ); let channel_box = css!( " padding: 16px; overflow: auto; grid-area: tree; " ); let chat_box = css!( " display: flex; flex-direction: row; grid-area: chat; border-left: solid var(--line-color) var(--line-width); @media screen and (max-width: 720px) { border-left:unset; border-top: solid var(--line-color) var(--line-width); } " ); let control_box = css!( " padding: 16px; margin: 16px; background-color: var(--light-bg-color); border-radius: 10px; overflow: hidden; grid-area: control; display: flex; gap: 10px; flex-direction: column; " ); rsx!( div { class: "{grid}", div { class: "{channel_box}", for (id, state) in server.channels.iter() { if state.parent.is_none() { Channel { id: *id } } } } div { class: "{chat_box}", ChatView {} } div { class: "{control_box}", ControlView { config } } } ) } #[component] pub fn LoginView(config: Resource) -> Element { let net: Coroutine = use_coroutine_handle(); let mut address_input = use_signal(|| None::); let mut 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 error = css!( " background-color: white; border-radius: 4px; overflow: auto; padding: 4px; color: red; pre { color: black; } " ); let login_box = css!( " max-width: 50vw; align-self: center; padding: 32px; border-radius: 16px; background-color: var(--login-bg-color); display: flex; flex-direction: column; gap: 16px; input,button { padding: 8px; } h1 { margin: 0; color: #b3c6b4; } " ); let bttn = css!( " font-weight: bold; font-size: large; " ); let do_connect = move |_| { //let _ = set_default_username(&username.read()); let _ = imp::set_default_username(&username.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: "{bttn}", onclick: do_connect.clone(), "Connect" } }, Connecting => rsx! { div { class: "{bttn}", "Connecting..." } }, Failed(msg) => rsx!( button { class: "{bttn}", onclick: do_connect.clone(), "Reconnect" } div { class: "{error}", "Failed to connect:" pre { "{msg}" } } ), Connected => unreachable!(), }; 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 { 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(), } }); global_css!( " :root { --txt-color: oklch(0.9 0 99); --bg-color: oklch(0.15 0.01 338.64); --light-bg-color: oklch(0.25 0.01 338.64); --login-bg-color: #5d7680; --primary-btn-color: #7bad9f; --accent-normal: #7bad9f; --accent-muted: #ff746c; --accent-deafened: #464459; --line-width: 2px; --line-color: white; } body { margin: 0; } #main { height: 100vh; display: flex; flex-direction: column; justify-content: space-around; background-color: var(--bg-color); overflow: auto; color: var(--txt-color); font-family: Nunito; font-size: 15pt; font-weight: 600; } button { font-weight: bold; font-size: medium; border: none; border-radius: 4px; color: var(--txt-color); background-color: var(--primary-btn-color); cursor: pointer; } input { border: none; border-radius: 4px; background-color: white; color: black; } input:focus,input:focus-visible { border: none; outline: solid var(--line-width) var(--accent-normal); outline-offset: -3px; } a:link { color: var(--accent-normal); } a:visited { color: var(--accent-muted); } " ); 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" } sir::AppStyle { } match *STATE.status.read() { Connected => rsx!(ServerView { config }), _ => rsx!(LoginView { config }), } ) }