4 Commits

Author SHA1 Message Date
restitux bd4620139f add background hover color change
Build Mumble Web 2 / linux_build (push) Successful in 1m39s
Build Mumble Web 2 / windows_build (push) Successful in 2m33s
Build Mumble Web 2 / android_build (push) Successful in 6m4s
2026-01-25 13:19:00 -07:00
restitux 37dc3ffa47 channel selection works a lot better 2026-01-25 13:15:26 -07:00
restitux feaa9f2bda gui: fix channel sort order (#17) (#20)
Build Mumble Web 2 / linux_build (push) Successful in 1m28s
Build Mumble Web 2 / windows_build (push) Successful in 2m44s
Build Mumble Web 2 / android_build (push) Successful in 5m56s
Build android container / android-release-builder-container-build (push) Successful in 7s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 14s
# Summary
Channel ordering is currently broken as described in #17.  This change makes sorting work correctly and cleans up the logic a bit.

# Changes
- creates a `ChannelsState` wrapper struct to handle this behavior
- moves the logic for handling `ChannelState` processing, including data update and parent-child tree sorting, into the impl for `ChannelsState`
- moves the logic for handling `ChannelRemove` into this impl

Parent child sorting properly applies the position values, which are arbitrary integers that are supposed to be sorted in numerical order. Lexicographical sorting is use for tiebreaking, which lines up (at least in my testing) with the official client's behavior. We may handle some lexicographical edge cases differently (spaces, symbols, etc) but 1. the Desktop client compliance is best effort and 2. users should use the position fields instead of relying on text sort order. Some compatibility is still helpful for matching temporary channel positioning, especially for servers with automated channel creation workflows.

This code is a bit complicated, as the mumble protocol makes no guarantees which order the channels will be sent. It ended up being simpler to just bulk recreate the children anytime any channel update is sent. I don't expect this to ever have performance issues, though maybe someday some server with 10,000 channels will send us a bug report 😆

# Testing
I tested this change by creating a bunch of channel with various sort orders and names. I compared the behavior with the official desktop client and our client seemed to follow along.

Reviewed-on: #20
2026-01-25 19:10:17 +00:00
restitux aa3fcf09cf meta: update dioxus cli to 0.7.3 (#19)
Build Mumble Web 2 / linux_build (push) Successful in 1m26s
Build Mumble Web 2 / windows_build (push) Successful in 2m37s
Build Mumble Web 2 / android_build (push) Successful in 5m54s
Reviewed-on: #19
Reviewed-by: Sam Sartor <cap@samsartor.com>
2026-01-25 06:25:21 +00:00
6 changed files with 206 additions and 63 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
- name: Install dioxus-cli - name: Install dioxus-cli
run: cargo binstall dioxus-cli --version 0.7.2 run: cargo binstall dioxus-cli --version 0.7.3
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
+1 -1
View File
@@ -29,7 +29,7 @@ RUN yes | /opt/android-tools/cmdline-tools/latest/bin/sdkmanager --install "plat
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
# Install dioxus-cli # Install dioxus-cli
RUN cargo binstall dioxus-cli@0.7.2 RUN cargo binstall dioxus-cli@0.7.3
# Install bindgen-cli # Install bindgen-cli
RUN cargo binstall bindgen-cli RUN cargo binstall bindgen-cli
+3 -5
View File
@@ -44,14 +44,12 @@ RUN choco install rustup.install -y --no-progress
RUN rustup toolchain install stable-x86_64-pc-windows-msvc RUN rustup toolchain install stable-x86_64-pc-windows-msvc
RUN rustup default stable-x86_64-pc-windows-msvc RUN rustup default stable-x86_64-pc-windows-msvc
# Install carog binstall # Install cargo binstall
RUN Set-ExecutionPolicy Unrestricted -Scope Process; ` RUN Set-ExecutionPolicy Unrestricted -Scope Process; `
iex (Invoke-WebRequest "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1" -UseBasicParsing).Content iex (Invoke-WebRequest "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1" -UseBasicParsing).Content
SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"] SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
# Install dioxus-cli from git HEAD with cargo # Install dioxus-cli
# This is to work around a bug in the windows builder upstream. RUN cargo binstall dioxus-cli@0.7.3
# Dioxus has released 0.7.2, but it seems to be broken for now.
RUN cargo binstall dioxus-cli
ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"] ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
+39 -1
View File
@@ -83,6 +83,44 @@ a:visited {
} }
} }
.channel_header {
display: flex;
flex-direction: row;
align-items: center;
}
.channel_arrow {
width: 1em;
text-align: center;
margin-right: 0.25rem;
}
.channel_arrow--placeholder {
pointer-events: none;
visibility: hidden;
}
/* The whole right side of the row is the dblclick target */
.channel_row_click {
flex: 1;
padding: 0.1rem 0.25rem 0.1rem 0.5rem;
cursor: pointer;
}
/* Hover highlight for whole row area (title + blank space) */
.channel_row_click:hover {
background-color: var(--channel-hover-bg, #222); /* pick your color */
}
/* still keep text non-selectable if desired */
.channel_details {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.channel { .channel {
&_details { &_details {
flex: 0 0 100%; flex: 0 0 100%;
@@ -392,4 +430,4 @@ a:visited {
color: red; color: red;
} }
} }
} }
+157 -21
View File
@@ -4,7 +4,7 @@ use dioxus::prelude::*;
use mime_guess::Mime; use mime_guess::Mime;
use mumble_web2_common::{ClientConfig, ServerStatus}; use mumble_web2_common::{ClientConfig, ServerStatus};
use ordermap::OrderSet; use ordermap::OrderSet;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use crate::imp; use crate::imp;
@@ -54,14 +54,6 @@ pub enum Command {
use Command::*; use Command::*;
use ConnectionState::*; use ConnectionState::*;
#[derive(Default)]
pub struct ChannelState {
pub name: String,
pub children: OrderSet<ChannelId>,
pub users: OrderSet<UserId>,
pub parent: Option<ChannelId>,
}
#[derive(Default)] #[derive(Default)]
pub struct UserState { pub struct UserState {
pub name: String, pub name: String,
@@ -94,8 +86,121 @@ pub struct Chat {
} }
#[derive(Default)] #[derive(Default)]
pub struct ServerState { pub struct ChannelState {
pub name: String,
pub children: OrderSet<ChannelId>,
pub users: OrderSet<UserId>,
pub parent: Option<ChannelId>,
pub position: i32,
}
impl ChannelState {
pub fn update_from_channel_state(
&mut self,
channel_state: &mumble_protocol::control::msgs::ChannelState,
) {
if channel_state.has_position() {
self.position = channel_state.get_position();
}
if channel_state.has_parent() {
self.parent = Some(channel_state.get_parent());
}
if channel_state.has_name() {
self.name = channel_state.get_name().to_string();
}
}
}
#[derive(Default)]
pub struct ChannelsState {
pub channels: HashMap<ChannelId, ChannelState>, pub channels: HashMap<ChannelId, ChannelState>,
}
impl ChannelsState {
pub fn update_from_channel_state(
&mut self,
channel_state: &mumble_protocol::control::msgs::ChannelState,
) {
self.channels
.entry(channel_state.get_channel_id())
.or_default()
.update_from_channel_state(channel_state);
self.update_channel_parents();
}
pub fn update_from_channel_remove(
&mut self,
channel_remove: &mumble_protocol::control::msgs::ChannelRemove,
) {
self.channels.remove(&channel_remove.get_channel_id());
self.update_channel_parents();
}
pub fn update_channel_parents(&mut self) {
// Zero out existing children
for state in self.channels.values_mut() {
state.children.clear();
}
let mut to_sort: Vec<(ChannelId, Option<ChannelId>, i32, String)> = Vec::new();
for (id, state) in self.channels.iter() {
// Handle channels with no parent (the root channel)
let Some(parent_id) = state.parent else {
to_sort.push((*id, None, 0, state.name.clone()));
continue;
};
// If a channel has a parent that we haven't gotten a channel
// state packet for, ignore it
if !self.channels.contains_key(&parent_id) {
continue;
}
to_sort.push((*id, Some(parent_id), state.position, state.name.clone()));
}
let pos_name: HashMap<ChannelId, (i32, String)> = self
.channels
.iter()
.map(|(&id, state)| (id, (state.position, state.name.clone())))
.collect();
let mut updated: HashSet<ChannelId> = HashSet::new();
while updated.len() < to_sort.len() {
for &(id, ref parent_id, position, ref name) in &to_sort {
let Some(parent_id) = parent_id else {
updated.insert(id);
continue;
};
if updated.contains(&id) || !updated.contains(&parent_id) {
continue;
}
// Unwrap should never fail here since we pre filter
let parent = self.channels.get_mut(&parent_id).unwrap();
let mut insert_index = parent.children.len();
for (i, &child) in parent.children.iter().enumerate() {
let (p, ref n) = pos_name[&child];
if (position == p && name < n) || p > position {
insert_index = i;
break;
}
}
parent.children.insert_before(insert_index, id);
updated.insert(id);
}
}
}
}
#[derive(Default)]
pub struct ServerState {
pub channels_state: ChannelsState,
pub users: HashMap<UserId, UserState>, pub users: HashMap<UserId, UserState>,
pub chat: Vec<Chat>, pub chat: Vec<Chat>,
pub session: Option<UserId>, pub session: Option<UserId>,
@@ -182,26 +287,57 @@ pub fn Channel(id: ChannelId) -> Element {
let net: Coroutine<Command> = use_coroutine_handle(); let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read(); let server = STATE.server.read();
let user = server.session.unwrap(); let user = server.session.unwrap();
let Some(state) = server.channels.get(&id) else { let Some(state) = server.channels_state.channels.get(&id) else {
return rsx!("missing channel {id}"); return rsx!("missing channel {id}");
}; };
let mut open = use_signal(|| true);
let has_children = !state.users.is_empty() || !state.children.is_empty();
rsx!( rsx!(
details { div {
class: "channel_details", class: "channel_details",
open: true,
summary { div {
span { class: "channel_header",
role: "button", // Arrow: only toggles open
ondoubleclick: move |evt| { if has_children {
span {
class: "channel_arrow",
onclick: move |evt| {
evt.stop_propagation();
evt.prevent_default();
let mut w = open.write();
*w = !*w;
},
if *open.read() { "" } else { "" }
}
} else {
span {
class: "channel_arrow channel_arrow--placeholder",
" "
}
}
// Clickable row area (everything except the arrow)
div {
class: "channel_row_click",
ondblclick: move |evt| {
evt.stop_propagation(); evt.stop_propagation();
evt.prevent_default(); evt.prevent_default();
net.send(EnterChannel { channel: id, user }) net.send(EnterChannel { channel: id, user })
}, },
"{state.name}" // remove dblclick from the inner span
span {
class: "channel_title",
"{state.name}"
}
// if you add icons/badges later, put them here too
} }
} }
if state.users.len() + state.children.len() > 0 {
if *open.read() && has_children {
div { div {
class: "channel_children", class: "channel_children",
for id in state.users.iter() { for id in state.users.iter() {
@@ -336,7 +472,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
return rsx!(); return rsx!();
}; };
let current_channel_name = server.channels[&channel].name.clone(); let current_channel_name = server.channels_state.channels[&channel].name.clone();
let proxy_url = config let proxy_url = config
.read_unchecked() .read_unchecked()
@@ -528,7 +664,7 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
class: "server_grid", class: "server_grid",
div { div {
class: "server_channel_box", class: "server_channel_box",
for (id, state) in server.channels.iter() { for (id, state) in server.channels_state.channels.iter() {
if state.parent.is_none() { if state.parent.is_none() {
Channel { id: *id } Channel { id: *id }
} }
+5 -34
View File
@@ -335,41 +335,11 @@ fn accept_packet(
} }
ControlPacket::ChannelState(u) => { ControlPacket::ChannelState(u) => {
let mut server = STATE.server.write(); let mut server = STATE.server.write();
let id = u.get_channel_id(); server.channels_state.update_from_channel_state(&u);
let state = server.channels.entry(id).or_default();
let new_parent = if u.has_parent() {
if let Some(parent) = state.parent.and_then(|p| server.channels.get_mut(&p)) {
parent.children.remove(&id);
}
let parent_id = u.get_parent();
let parent = server.channels.entry(parent_id).or_default();
if u.has_position() && u.get_position() as usize <= parent.children.len() {
// TODO: what if positions are received out of order? we need to sort afterwards?
parent.children.insert_before(u.get_position() as usize, id);
} else {
parent.children.insert(id);
}
Some(parent_id)
} else {
None
};
let state = server.channels.entry(id).or_default();
state.parent = new_parent;
if u.has_name() {
state.name = u.get_name().to_string();
}
} }
ControlPacket::ChannelRemove(u) => { ControlPacket::ChannelRemove(u) => {
let mut server = STATE.server.write(); let mut server = STATE.server.write();
let id = u.get_channel_id(); server.channels_state.update_from_channel_remove(&u);
if let Some(channel) = server.channels.remove(&id) {
if let Some(parent) = channel.parent.and_then(|p| server.channels.get_mut(&p)) {
parent.children.remove(&id);
}
}
} }
ControlPacket::UserState(u) => { ControlPacket::UserState(u) => {
let mut server = STATE.server.write(); let mut server = STATE.server.write();
@@ -381,12 +351,13 @@ fn accept_packet(
let state = state_entry.or_default(); let state = state_entry.or_default();
// the server might now send a channel_id if the user is in channel=0 // the server might now send a channel_id if the user is in channel=0
if u.has_channel_id() || new { if u.has_channel_id() || new {
if let Some(parent) = server.channels.get_mut(&state.channel) { if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
parent.users.remove(&id); parent.users.remove(&id);
} }
let channel_id = u.get_channel_id(); let channel_id = u.get_channel_id();
server server
.channels_state
.channels .channels
.entry(channel_id) .entry(channel_id)
.or_default() .or_default()
@@ -418,7 +389,7 @@ fn accept_packet(
let mut server = STATE.server.write(); let mut server = STATE.server.write();
let id = u.get_session(); let id = u.get_session();
if let Some(state) = server.users.remove(&id) { if let Some(state) = server.users.remove(&id) {
if let Some(parent) = server.channels.get_mut(&state.channel) { if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
parent.users.remove(&id); parent.users.remove(&id);
} }
} }