Files
mumble-web2/src/app.rs
T
2024-11-08 23:32:07 -07:00

491 lines
11 KiB
Rust

#![allow(non_snake_case)]
use dioxus::prelude::*;
use ordermap::OrderSet;
use sir::{css, global_css};
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,
},
SendChat {
markdown: String,
channels: Vec<ChannelId>,
},
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<ChannelId>,
pub users: OrderSet<UserId>,
pub parent: Option<ChannelId>,
}
#[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,
}
pub struct Chat {
pub raw: String,
pub dangerous_html: String,
pub sender: Option<UserId>,
}
#[derive(Default)]
pub struct ServerState {
pub channels: HashMap<ChannelId, ChannelState>,
pub users: HashMap<UserId, UserState>,
pub chat: Vec<Chat>,
pub session: Option<UserId>,
}
impl ServerState {
pub fn this_user(&self) -> Option<&UserState> {
self.users.get(&self.session?)
}
}
pub struct State {
pub status: GlobalSignal<ConnectionState>,
pub server: GlobalSignal<ServerState>,
}
pub static STATE: State = State {
status: Signal::global(|| Disconnected),
server: Signal::global(|| Default::default()),
};
#[component]
pub fn UserPill(name: String) -> Element {
let pill = css!(
"
border: solid 1px black;
border-radius: 8px;
padding: 4px;
width: fit-content;
"
);
rsx!(
div {
class: "{pill}",
"{name}"
}
)
}
#[component]
pub fn User(id: UserId) -> Element {
let server = STATE.server.read();
let state = server.users.get(&id)?;
rsx!(UserPill {
name: state.name.clone()
})
}
#[component]
pub fn Channel(id: ChannelId) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let user = server.session?;
let state = server.channels.get(&id)?;
let channel_details = css!(
"
flex: 0 0 100%;
"
);
let channel_children = css!(
"
border-left: solid black 1px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
margin-left: 5px;
padding-left: 11px;
"
);
rsx!(
details {
class: "{channel_details}",
open: true,
summary {
ondoubleclick: move |_| net.send(EnterChannel { channel: id, user }),
"{state.name}"
}
div {
class: "{channel_children}",
for id in state.users.iter() {
User { id: *id }
}
for child in state.children.iter() {
Channel { id: *child }
}
}
}
)
}
#[component]
pub fn ChatView() -> Element {
let net: Coroutine<Command> = 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 black 1px;
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() }
}
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<KeyboardData>| {
if evt.code() == Code::Enter && evt.modifiers().is_empty() {
do_send();
}
}
}
button {
onclick: move |_| do_send(),
"Send"
}
}
)
}
#[component]
pub fn ServerView() -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let &UserState {
deaf,
self_deaf,
mute,
self_mute,
..
} = server.this_user()?;
let grid = css!(
r#"
display: grid;
height: 100%;
background-color: white;
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";
}
gap: 4px;
padding: 4px;
"#
);
let channel_box = css!(
"
padding: 16px;
border: solid black 1px;
overflow: auto;
grid-area: tree;
"
);
let chat_box = css!(
"
border: solid black 1px;
display: flex;
flex-direction: column;
grid-area: chat;
"
);
let top_bar = css!(
"
padding: 16px;
border: solid black 1px;
grid-area: bar;
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
button {
padding: 8px;
}
"
);
rsx!(
div {
class: "{grid}",
div {
class: "{top_bar}",
button {
onclick: move |_| net.send(Disconnect),
"Disconnect"
}
span {
input {
r#type: "checkbox",
id: "mute",
checked: mute || self_mute,
disabled: mute,
onchange: move |_| net.send(SetMute { mute: !self_mute }),
}
label {
r#for: "mute",
"Mute"
}
}
span {
input {
r#type: "checkbox",
id: "deaf",
checked: deaf || self_deaf,
disabled: deaf,
onchange: move |_| net.send(SetDeaf { deaf: !self_deaf }),
}
label {
r#for: "deaf",
"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<Command> = use_coroutine_handle();
let default_address = option_env!("MUMBLEWEB2_WEBTRANSPORT_SERVER_ADDRESS").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!(
"
color: red;
pre {
color: black;
}
"
);
let login_box = css!(
"
max-width: 50vw;
align-self: center;
padding: 16px;
background-color: white;
display: flex;
flex-direction: column;
gap: 16px;
input,button {
padding: 8px;
}
"
);
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 {
onclick: do_connect.clone(),
"Connect!"
}
},
Connecting => rsx! {
"Connecting..."
},
Failed(msg) => rsx!(
button {
onclick: do_connect.clone(),
"Reconnect!"
}
div {
class: "{error}",
"Failed to connect:"
pre {
"{msg}"
}
}
),
Connected => unreachable!(),
};
rsx!(
div {
class: "{login_box}",
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<Command>| super::network_entrypoint(rx));
global_css!(
"
body {
margin: 0;
}
#main {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-around;
background-color: grey;
overflow: auto;
}
"
);
rsx!(
sir::AppStyle { }
match *STATE.status.read() {
Connected => rsx!(ServerView {}),
_ => rsx!(LoginView {}),
}
)
}