#![allow(non_snake_case)] use base64::{display::Base64Display, prelude::BASE64_URL_SAFE}; use dioxus::prelude::*; use mime_guess::Mime; use ordermap::OrderSet; use sir::{css, global_css}; use std::collections::HashMap; use crate::{imp, CONFIG}; 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, }, 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, }, 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_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 = css!( " display: flex; flex-direction: row; padding: 16px; gap: 8px; border-top: solid var(--line-color) var(--line-width); input { flex-grow: 1; padding: 8px; } " ); 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_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}", 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(); } } } button { onclick: move |_| pick_and_send_file(&net), "File" } button { onclick: move |_| do_send(), "Send" } } ) } #[component] pub fn ServerView() -> 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: auto 1fr; grid-template-columns: 1fr 1fr; grid-template-areas: "bar bar" "tree chat"; @media screen and (max-width: 720px) { grid-template-rows: auto 1fr 1fr; grid-template-columns: 1fr; grid-template-areas: "bar" "tree" "chat"; } "# ); let channel_box = css!( " padding: 16px; overflow: auto; grid-area: tree; " ); let chat_box = css!( " display: flex; flex-direction: column; 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 top_bar = css!( " padding: 16px; grid-area: bar; background-color: var(--login-bg-color); display: flex; flex-direction: row; gap: 16px; align-items: center; button { padding: 8px; img { height: 1em; vertical-align: text-bottom; } } " ); rsx!( div { class: "{grid}", div { class: "{top_bar}", button { onclick: move |_| net.send(Disconnect), "Disconnect" } button { role: "switch", aria_checked: mute || self_mute, disabled: mute, onclick: move |_| net.send(SetMute { mute: !self_mute }), match mute || self_mute { true => rsx!(img { src: asset!("assets/mic-off-svgrepo-com.svg") }), false => rsx!(img { src: asset!("assets/mic-svgrepo-com.svg") }), } "\u{00A0}Mute" } button { role: "switch", aria_checked: deaf || self_deaf, disabled: deaf, onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }), match deaf || self_deaf { true => rsx!(img { src: asset!("assets/speaker-muted-svgrepo-com.svg") }), false => rsx!(img { src: asset!("assets/speaker-medium-svgrepo-com.svg") }), } "\u{00A0}Deafen" } } div { class: "{channel_box}", for (id, state) in server.channels.iter() { if state.parent.is_none() { Channel { id: *id } } } } div { class: "{chat_box}", ChatView {} } } ) } #[component] pub fn LoginView() -> Element { let net: Coroutine = use_coroutine_handle(); let default_address = CONFIG.proxy_url.as_deref().unwrap_or(""); let mut address = use_signal(|| default_address.to_string()); 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(), }) }; 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.set(evt.value().clone()), } {bottom} } ) } pub fn app() -> Element { use_coroutine(|rx: UnboundedReceiver| super::network_entrypoint(rx)); global_css!( " :root { --txt-color: white; --bg-color: #372f3a; --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: sans-serif; font-size: large; } 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!( sir::AppStyle { } match *STATE.status.read() { Connected => rsx!(ServerView {}), _ => rsx!(LoginView {}), } ) }