move into gui folder for monorepo
This commit is contained in:
+632
@@ -0,0 +1,632 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use manganis::mg;
|
||||
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<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,
|
||||
}
|
||||
|
||||
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<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()),
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UserIcon {
|
||||
Normal,
|
||||
Muted,
|
||||
Deafened,
|
||||
None,
|
||||
}
|
||||
|
||||
impl UserIcon {
|
||||
pub fn url(self) -> Option<&'static str> {
|
||||
// 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 => "/mic-svgrepo-com.svg",
|
||||
Muted => "/mic-off-svgrepo-com.svg",
|
||||
Deafened => "/speaker-muted-svgrepo-com.svg",
|
||||
None => return Option::None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn UserPill(name: String, icon: UserIcon) -> Element {
|
||||
let pill = css!(
|
||||
"
|
||||
border-radius: 100px;
|
||||
padding: 4px 8px;
|
||||
width: fit-content;
|
||||
|
||||
img {
|
||||
height: 1em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
let color = match icon {
|
||||
UserIcon::Normal => "var(--accent-a)",
|
||||
UserIcon::Muted => "var(--accent-b)",
|
||||
UserIcon::Deafened => "var(--accent-c)",
|
||||
UserIcon::None => "var(--accent-a)",
|
||||
};
|
||||
|
||||
rsx!(
|
||||
div {
|
||||
class: "{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();
|
||||
let state = server.users.get(&id)?;
|
||||
rsx!(UserPill {
|
||||
name: state.name.clone(),
|
||||
icon: state.icon(),
|
||||
})
|
||||
}
|
||||
|
||||
#[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%;
|
||||
|
||||
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",
|
||||
prevent_default: "onclick",
|
||||
ondoubleclick: move |evt| {
|
||||
evt.stop_propagation();
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[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 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,
|
||||
}
|
||||
}
|
||||
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: 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: "/mic-off-svgrepo-com.svg" }),
|
||||
false => rsx!(img { src: "/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: "/speaker-muted-svgrepo-com.svg" }),
|
||||
false => rsx!(img { src: "/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<Command> = 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<Command>| super::network_entrypoint(rx));
|
||||
|
||||
global_css!(
|
||||
"
|
||||
:root {
|
||||
--txt-color: white;
|
||||
--bg-color: #372f3a;
|
||||
--login-bg-color: #5d7680;
|
||||
--primary-btn-color: #7bad9f;
|
||||
--accent-a: #8eb29a;
|
||||
--accent-b: #6a9395;
|
||||
--accent-c: #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-a);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
a:link {
|
||||
color: var(--accent-a);
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: var(--accent-b);
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
rsx!(
|
||||
sir::AppStyle { }
|
||||
match *STATE.status.read() {
|
||||
Connected => rsx!(ServerView {}),
|
||||
_ => rsx!(LoginView {}),
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user