491 lines
11 KiB
Rust
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 {}),
|
|
}
|
|
)
|
|
}
|