Phase 2: E2EE for DM + Slint migration + dead code cleanup

- E2EE crypto (src/e2ee.rs) with X25519 key derivation from Ed25519,
  HKDF shared secret, ChaCha20-Poly1305 encrypt/decrypt
- E2EE net layer: PID 0x0071-0x0074, JSON payloads, dispatch, session handlers
- E2EE UI: lock icons in DM header and sidebar
- Slint migration: all .slint files, bridge.rs, update.rs, main.rs
- Removed egui UI (~50 files), cleaned all dead code
- Clean cargo clippy -- -D warnings
This commit is contained in:
loki5512344 2026-07-14 13:25:15 +02:00
parent f361b037cb
commit 03d94e0465
Signed by: boba
GPG key ID: 253067914055423B
83 changed files with 5055 additions and 9202 deletions

3491
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -24,10 +24,14 @@ path = "src/main.rs"
name = "vnox-e2e-voice"
path = "src/bin/e2e_voice.rs"
[build-dependencies]
slint-build = "1"
[dependencies]
tokio = { version = "1.52", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_bytes = "0.11"
ed25519-dalek = { version = "2", features = ["rand_core"] }
x25519-dalek = { version = "2", features = ["static_secrets"] }
chacha20poly1305 = "0.10"
@ -44,7 +48,7 @@ opus = "0.3"
cpal = "0.18"
rodio = "0.22"
dirs = "6"
eframe = { version = "0.33", features = ["wgpu"] }
slint = { version = "1", features = ["backend-winit", "compat-1-2"] }
nnnoiseless = { version = "0.5.2", default-features = false, optional = true }
[features]

3
build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
slint_build::compile("src/ui/slint/main.slint").unwrap();
}

View file

@ -2,15 +2,15 @@ use crate::jitter::{BufferedPacket, JitterBuffer};
use anyhow::Result;
use cpal::traits::{DeviceTrait, StreamTrait};
use std::sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
mpsc::TryRecvError,
Arc, Mutex,
};
use std::time::{Duration, Instant};
use tracing::{info, warn};
use crate::audio::config::{self, AudioConfig, OPUS_RATE};
use crate::audio::IncomingVoice;
use crate::audio::config::{self, AudioConfig, OPUS_RATE};
#[derive(Debug, Clone)]
pub struct JitterConfig {

43
src/e2ee.rs Normal file
View file

@ -0,0 +1,43 @@
use chacha20poly1305::aead::Aead;
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce};
use hkdf::Hkdf;
use sha2::{Digest, Sha256, Sha512};
use x25519_dalek::{PublicKey, StaticSecret};
use crate::identity::Identity;
pub fn derive_e2ee_keypair(identity: &Identity) -> (StaticSecret, PublicKey) {
let sk = identity.signing_key().expect("signing key required");
let hash = Sha512::digest(sk.to_bytes());
let mut e2ee_seed = [0u8; 32];
e2ee_seed.copy_from_slice(&hash[..32]);
let static_secret = StaticSecret::from(e2ee_seed);
let public = PublicKey::from(&static_secret);
(static_secret, public)
}
pub fn compute_shared_secret(my_secret: &StaticSecret, peer_public_bytes: &[u8]) -> [u8; 32] {
let mut peer_pk = [0u8; 32];
peer_pk.copy_from_slice(peer_public_bytes);
let peer_public = PublicKey::from(peer_pk);
let shared = my_secret.diffie_hellman(&peer_public);
let hk = Hkdf::<Sha256>::new(Some(b"VNOX-E2EE-v1"), shared.as_bytes());
let mut msg_key = [0u8; 32];
hk.expand(b"dm-key", &mut msg_key).expect("32 bytes");
msg_key
}
pub fn encrypt_message(msg_key: &[u8; 32], message_id: &str, plaintext: &str) -> Vec<u8> {
let cipher = ChaCha20Poly1305::new_from_slice(msg_key).unwrap();
let hash = Sha256::digest(message_id.as_bytes());
let nonce = Nonce::from_slice(&hash[..12]);
cipher.encrypt(nonce, plaintext.as_bytes()).unwrap()
}
pub fn decrypt_message(msg_key: &[u8; 32], message_id: &str, ciphertext: &[u8]) -> String {
let cipher = ChaCha20Poly1305::new_from_slice(msg_key).unwrap();
let hash = Sha256::digest(message_id.as_bytes());
let nonce = Nonce::from_slice(&hash[..12]);
let plaintext = cipher.decrypt(nonce, ciphertext).unwrap();
String::from_utf8(plaintext).unwrap()
}

View file

@ -1,4 +1,5 @@
pub mod audio;
pub mod e2ee;
pub mod identity;
pub mod identity_vault;
pub mod jitter;

View file

@ -1,17 +1,36 @@
mod ui;
pub use ui::VnoxApp;
pub use ui::{chat, connect, keybind, members, settings, sidebar, state, theme, titlebar};
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
use crate::ui::slint::MainWindow;
use anyhow::Result;
use slint::ComponentHandle;
use tracing::info;
use tracing_subscriber::EnvFilter;
use vnox_client::{identity, net};
use vnox_client::audio::AudioPipeline;
use vnox_client::audio::PipelineConfig;
use vnox_client::identity;
use vnox_client::net;
use vnox_client::net::NetCommand;
use vnox_client::net::NetHandle;
use crate::ui::state::{ConnState, UiState, VoiceUiState};
pub struct AppState {
pub(crate) identity: identity::Identity,
pub(crate) net: NetHandle,
pub(crate) ui: UiState,
pub(crate) audio: Option<AudioPipeline>,
pub(crate) voice_channel: Option<String>,
pub(crate) last_pipeline_config: Option<PipelineConfig>,
}
fn main() -> Result<()> {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new("info,wgpu=warn,wgpu_hal=warn,egui_wgpu=warn,naga=warn")
});
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,wgpu=warn,wgpu_hal=warn,naga=warn"));
tracing_subscriber::fmt().with_env_filter(filter).init();
let identity = identity::load_or_generate()?;
@ -24,16 +43,306 @@ fn main() -> Result<()> {
let net = net::spawn(identity.clone());
eframe::run_native(
"Vnox",
eframe::NativeOptions {
viewport: eframe::egui::ViewportBuilder::default()
.with_title("Vnox")
.with_inner_size([900.0, 560.0])
.with_min_inner_size([600.0, 400.0]),
..Default::default()
},
Box::new(|cc| Ok(Box::new(ui::VnoxApp::new(cc, identity, net)))),
)
.map_err(|e| anyhow::anyhow!("eframe: {e}"))
let state = Rc::new(RefCell::new(AppState {
identity,
net,
ui: UiState::default(),
audio: None,
voice_channel: None,
last_pipeline_config: None,
}));
let window = MainWindow::new()?;
wire_signals(&window, &state);
let timer = slint::Timer::default();
{
let state = state.clone();
let weak = window.as_weak();
timer.start(
slint::TimerMode::Repeated,
Duration::from_millis(16),
move || {
if let Some(win) = weak.upgrade() {
let mut s = state.borrow_mut();
crate::ui::update::process_net_events(&mut s);
let own_id = s.identity.short_id().to_string();
crate::ui::bridge::sync_ui(&win, &s.ui, &own_id);
}
},
);
}
window.run()?;
Ok(())
}
fn wire_signals(window: &MainWindow, state: &Rc<RefCell<AppState>>) {
{
let state = state.clone();
window.on_channel_selected(move |id| {
let id = id.to_string();
let mut s = state.borrow_mut();
s.ui.active_channel = Some(id.clone());
s.ui.active_dm_id = None;
let net = s.net.clone();
drop(s);
tokio::spawn(async move {
net.send(NetCommand::JoinChannel { channel_id: id }).await;
});
});
}
{
let state = state.clone();
window.on_guild_selected(move |idx| {
let mut s = state.borrow_mut();
s.ui.active_guild_id = if idx >= 0 && (idx as usize) < s.ui.guilds.len() {
Some(s.ui.guilds[idx as usize].guild_id.clone())
} else {
None
};
});
}
{
let state = state.clone();
window.on_dm_selected(move |nickname| {
let nick = nickname.to_string();
let mut s = state.borrow_mut();
if let Some(conv) =
s.ui.dm_conversations
.iter()
.find(|c| c.other_nickname == nick)
{
s.ui.active_dm_id = Some(conv.dm_id.clone());
s.ui.active_channel = None;
}
});
}
{
let state = state.clone();
window.on_connect(move |addr| {
let addr = addr.to_string();
let mut s = state.borrow_mut();
s.ui.conn = ConnState::Connecting;
s.ui.connect_input = addr.clone();
s.ui.status_msg = Some("connecting...".into());
let net = s.net.clone();
drop(s);
tokio::spawn(async move {
net.send(NetCommand::Connect {
address: addr,
auto_reconnect: true,
})
.await;
});
});
}
{
let state = state.clone();
window.on_disconnect(move || {
let s = state.borrow();
let net = s.net.clone();
drop(s);
tokio::spawn(async move {
net.send(NetCommand::Disconnect).await;
});
});
}
{
let state = state.clone();
window.on_send_message(move |ch_id, text| {
let ch_id = ch_id.to_string();
let text = text.to_string();
if text.is_empty() {
return;
}
let mut s = state.borrow_mut();
s.ui.chat_input = String::new();
let net = s.net.clone();
drop(s);
if !ch_id.is_empty() {
tokio::spawn(async move {
net.send(NetCommand::SendChat {
channel_id: ch_id,
content: text,
reply_to: None,
})
.await;
});
} else {
let s = state.borrow();
if let Some(dm_id) = s.ui.active_dm_id.clone() {
let net = s.net.clone();
drop(s);
tokio::spawn(async move {
net.send(NetCommand::DmSend {
dm_id,
content: text,
})
.await;
});
}
}
});
}
{
let state = state.clone();
window.on_add_friend(move |user_id| {
let user_id = user_id.to_string();
let s = state.borrow();
let net = s.net.clone();
drop(s);
tokio::spawn(async move {
net.send(NetCommand::FriendRequest {
target_user_id: user_id,
})
.await;
});
});
}
{
let state = state.clone();
window.on_accept_friend(move |user_id| {
let user_id = user_id.to_string();
let s = state.borrow();
let net = s.net.clone();
drop(s);
tokio::spawn(async move {
net.send(NetCommand::FriendAccept { user_id }).await;
});
});
}
{
let state = state.clone();
window.on_decline_friend(move |user_id| {
let user_id = user_id.to_string();
let s = state.borrow();
let net = s.net.clone();
drop(s);
tokio::spawn(async move {
net.send(NetCommand::FriendDecline { user_id }).await;
});
});
}
{
let state = state.clone();
window.on_remove_friend(move |user_id| {
let user_id = user_id.to_string();
let s = state.borrow();
let net = s.net.clone();
drop(s);
tokio::spawn(async move {
net.send(NetCommand::FriendRemove { user_id }).await;
});
});
}
{
let state = state.clone();
window.on_start_dm(move |user_id| {
let user_id = user_id.to_string();
let s = state.borrow();
let net = s.net.clone();
drop(s);
tokio::spawn(async move {
net.send(NetCommand::DmStart {
target_user_id: user_id,
})
.await;
});
});
}
{
let state = state.clone();
window.on_toggle_mic(move || {
let mut s = state.borrow_mut();
s.ui.mic_enabled = !s.ui.mic_enabled;
});
}
{
let state = state.clone();
window.on_toggle_deafen(move || {
let mut s = state.borrow_mut();
s.ui.deafen = !s.ui.deafen;
});
}
{
let state = state.clone();
window.on_leave_voice(move || {
let mut s = state.borrow_mut();
s.ui.active_channel = None;
s.voice_channel = None;
s.audio = None;
s.last_pipeline_config = None;
s.ui.voice = VoiceUiState::Off;
s.ui.voice_joined_at = None;
});
}
{
let state = state.clone();
window.on_add_bookmark(move |_addr| {
state.borrow_mut().ui.add_bookmark_from_input();
});
}
{
let state = state.clone();
window.on_remove_bookmark(move |address| {
let addr = address.to_string();
let mut s = state.borrow_mut();
if let Some(idx) = s.ui.bookmarks.iter().position(|b| b.address == addr) {
s.ui.remove_bookmark(idx);
}
});
}
{
let state = state.clone();
window.on_load_more(move |ch_id| {
let _ch_id = ch_id.to_string();
let state = state.borrow();
let net = state.net.clone();
drop(state);
tokio::spawn(async move {
net.send(NetCommand::ChannelList).await;
});
});
}
{
let state = state.clone();
window.on_close_settings(move || {
state.borrow_mut().ui.settings_open = false;
});
}
{
let _state = state.clone();
window.on_set_passphrase(move |_pass| {});
}
{
let _state = state.clone();
window.on_export_keyfile(move || {});
}
{
let _state = state.clone();
window.on_import_keyfile(move || {});
}
}

View file

@ -146,6 +146,41 @@ pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) -> Re
_ => debug!("unhandled friend event: {}", p.event_type),
}
}
PID_E2EE_DM_KEY_EXCHANGE => {
let p: E2eeDmKeyExchangePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::E2eeDmKeyExchange {
dm_id: p.dm_id,
e2ee_public_key: p.e2ee_public_key,
})
.await;
}
PID_E2EE_DM_KEY_EXCHANGE_ACK => {
let p: E2eeDmKeyExchangeAckPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::E2eeDmKeyExchangeAck { dm_id: p.dm_id })
.await;
}
PID_E2EE_DM_MESSAGE => {
let p: E2eeDmMessagePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::E2eeDmMessage {
dm_id: p.dm_id,
sender_id: p.sender_id,
ciphertext: p.ciphertext,
timestamp: p.timestamp,
})
.await;
}
PID_E2EE_DM_HISTORY => {
let p: E2eeDmHistoryPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::E2eeDmHistory {
dm_id: p.dm_id,
messages: p.messages,
})
.await;
}
_ => {}
}
Ok(())

View file

@ -30,7 +30,11 @@ pub async fn incoming(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) ->
| PID_MESSAGE_REACTION_REMOVE
| PID_MESSAGE_EDIT
| PID_MESSAGE_DELETE
| PID_FRIEND_EVENT => dm::handle(pid, payload, tx).await?,
| PID_FRIEND_EVENT
| PID_E2EE_DM_KEY_EXCHANGE
| PID_E2EE_DM_KEY_EXCHANGE_ACK
| PID_E2EE_DM_MESSAGE
| PID_E2EE_DM_HISTORY => dm::handle(pid, payload, tx).await?,
PID_GUILD_LIST
| PID_GUILD_CREATE

View file

@ -1,5 +1,34 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct E2eeDmKeyExchangePayload {
pub dm_id: String,
pub e2ee_public_key: Vec<u8>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct E2eeDmKeyExchangeAckPayload {
pub dm_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct E2eeDmMessagePayload {
pub dm_id: String,
pub sender_id: String,
#[serde(with = "serde_bytes")]
pub ciphertext: Vec<u8>,
pub timestamp: i64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct E2eeDmHistoryPayload {
pub dm_id: String,
#[serde(default)]
pub messages: Vec<E2eeDmMessagePayload>,
#[serde(default)]
pub limit: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DmStartPayload {
pub target_user_id: String,

View file

@ -26,6 +26,10 @@ pub const PID_MESSAGE_DELETE: u16 = 0x0069;
pub const PID_READ_RECEIPT: u16 = 0x0064;
pub const PID_READ_RECEIPT_BROADCAST: u16 = 0x0065;
pub const PID_TYPING_START: u16 = 0x0070;
pub const PID_E2EE_DM_KEY_EXCHANGE: u16 = 0x0071;
pub const PID_E2EE_DM_KEY_EXCHANGE_ACK: u16 = 0x0072;
pub const PID_E2EE_DM_MESSAGE: u16 = 0x0073;
pub const PID_E2EE_DM_HISTORY: u16 = 0x0074;
pub const PID_GUILD_CREATE: u16 = 0x0100;
pub const PID_GUILD_DELETE: u16 = 0x0101;
pub const PID_GUILD_LIST: u16 = 0x0102;

View file

@ -140,6 +140,53 @@ pub async fn handle_cmd(
})?;
framing::io::write_encrypted(stream, PID_DM_HISTORY, seq, &payload, crypto).await?;
}
NetCommand::E2eeDmKeyExchange {
dm_id,
e2ee_public_key,
} => {
let payload = serde_json::to_vec(&E2eeDmKeyExchangePayload {
dm_id,
e2ee_public_key,
})?;
framing::io::write_encrypted(stream, PID_E2EE_DM_KEY_EXCHANGE, seq, &payload, crypto)
.await?;
}
NetCommand::E2eeDmKeyExchangeAck { dm_id } => {
let payload = serde_json::to_vec(&E2eeDmKeyExchangeAckPayload { dm_id })?;
framing::io::write_encrypted(
stream,
PID_E2EE_DM_KEY_EXCHANGE_ACK,
seq,
&payload,
crypto,
)
.await?;
}
NetCommand::E2eeDmSend { dm_id, ciphertext } => {
let msg = E2eeDmMessagePayload {
dm_id,
sender_id: identity.pubkey_hex.clone(),
ciphertext,
timestamp: now_ms(),
};
framing::io::write_encrypted(
stream,
PID_E2EE_DM_MESSAGE,
seq,
&serde_json::to_vec(&msg)?,
crypto,
)
.await?;
}
NetCommand::E2eeDmHistory { dm_id, limit } => {
let payload = serde_json::to_vec(&E2eeDmHistoryPayload {
dm_id,
messages: vec![],
limit,
})?;
framing::io::write_encrypted(stream, PID_E2EE_DM_HISTORY, seq, &payload, crypto)
.await?;
}
_ => {}
}
Ok(())

View file

@ -158,5 +158,20 @@ pub enum NetCommand {
channel_id: String,
message_id: String,
},
E2eeDmKeyExchange {
dm_id: String,
e2ee_public_key: Vec<u8>,
},
E2eeDmKeyExchangeAck {
dm_id: String,
},
E2eeDmSend {
dm_id: String,
ciphertext: Vec<u8>,
},
E2eeDmHistory {
dm_id: String,
limit: Option<i64>,
},
Disconnect,
}

View file

@ -224,4 +224,21 @@ pub enum NetEvent {
channel_id: String,
message_id: String,
},
E2eeDmKeyExchange {
dm_id: String,
e2ee_public_key: Vec<u8>,
},
E2eeDmKeyExchangeAck {
dm_id: String,
},
E2eeDmMessage {
dm_id: String,
sender_id: String,
ciphertext: Vec<u8>,
timestamp: i64,
},
E2eeDmHistory {
dm_id: String,
messages: Vec<crate::net::payloads::E2eeDmMessagePayload>,
},
}

View file

@ -1,51 +0,0 @@
use eframe::egui::{self, Frame};
use crate::connect;
use crate::state::ConnState;
use crate::{chat, members, settings, sidebar, theme, titlebar};
use super::VnoxApp;
impl eframe::App for VnoxApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.handle_voice_keybinds(ctx);
self.sync_audio_controls(ctx);
self.poll_net();
if matches!(
self.ui.conn,
ConnState::Connecting | ConnState::Reconnecting { .. }
) {
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
if matches!(self.ui.conn, ConnState::Connected { .. }) {
ctx.request_repaint_after(std::time::Duration::from_secs(1));
}
if self.audio.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(20));
}
titlebar::show_titlebar(ctx, &mut self.ui);
if matches!(self.ui.conn, ConnState::Connected { .. }) {
sidebar::guild_bar(ctx, &mut self.ui, &self.net);
}
sidebar::show(ctx, &mut self.ui, &self.identity, &self.net);
if matches!(self.ui.conn, ConnState::Connected { .. }) {
members::show(ctx, &mut self.ui);
}
egui::CentralPanel::default()
.frame(Frame::NONE.fill(theme::BG_BASE))
.show(ctx, |ui| match &self.ui.conn.clone() {
ConnState::Disconnected
| ConnState::Connecting
| ConnState::Reconnecting { .. } => {
connect::show(ui, &mut self.ui, &self.identity, &self.net)
}
ConnState::Connected { .. } => {
chat::show(ui, ctx, &mut self.ui, &self.identity, &self.net)
}
});
settings::show_window(ctx, &mut self.ui, &self.identity);
}
}

View file

@ -1,123 +0,0 @@
mod frame;
mod net_poll;
mod setup;
use vnox_client::{
audio::{AudioPipeline, AudioSync},
identity::Identity,
net::{NetCommand, NetHandle},
};
use crate::keybind;
use crate::state::{ConnState, VoiceUiState};
use crate::theme::apply_visuals;
pub struct VnoxApp {
pub(crate) identity: Identity,
pub(crate) net: NetHandle,
pub(crate) ui: crate::state::UiState,
pub(crate) audio: Option<AudioPipeline>,
pub(crate) voice_channel: Option<String>,
pub(crate) last_pipeline_config: Option<vnox_client::audio::PipelineConfig>,
}
impl VnoxApp {
pub fn new(cc: &eframe::CreationContext, identity: Identity, net: NetHandle) -> Self {
apply_visuals(&cc.egui_ctx);
Self {
identity,
net,
ui: crate::state::UiState::default(),
audio: None,
voice_channel: None,
last_pipeline_config: None,
}
}
pub(crate) fn stop_voice(&mut self) {
self.audio = None;
self.voice_channel = None;
self.last_pipeline_config = None;
self.ui.voice = VoiceUiState::Off;
self.ui.voice_joined_at = None;
}
pub(crate) fn sync_voice_audio(&mut self) {
if !matches!(self.ui.conn, ConnState::Connected { .. }) {
self.stop_voice();
return;
}
let active_voice = self
.ui
.active_channel
.as_ref()
.and_then(|id| self.ui.channels.iter().find(|c| &c.id == id))
.filter(|c| c.kind == "voice")
.map(|c| c.id.clone());
match active_voice {
Some(id) => self.start_voice(&id),
None => self.stop_voice(),
}
}
pub(crate) fn sync_audio_controls(&mut self, ctx: &eframe::egui::Context) {
let Some(pipeline) = self.audio.as_ref() else {
return;
};
let ptt_held = if self.ui.vad_mode == 0 {
keybind::binding_held(ctx, &self.ui.kb_ptt)
} else {
false
};
pipeline.sync_controls(AudioSync {
mic_enabled: self.ui.mic_enabled,
vad_mode: self.ui.vad_mode,
vad_threshold: self.ui.vad_threshold,
input_volume: self.ui.input_volume,
ptt_held,
output_volume: self.ui.output_volume,
jitter_size: self.ui.jitter_size,
adaptive_buffer: self.ui.adaptive_buffer,
voice_bitrate_kbps: self.ui.voice_bitrate,
noise_suppress: self.ui.noise_suppress,
});
if let Some(ch) = self.voice_channel.clone() {
let cfg = self.pipeline_config(&ch);
if self.last_pipeline_config.as_ref() != Some(&cfg) {
self.start_voice(&ch);
}
}
}
pub(crate) fn handle_voice_keybinds(&mut self, ctx: &eframe::egui::Context) {
if ctx.wants_keyboard_input() {
return;
}
if keybind::binding_pressed(ctx, &self.ui.kb_mute) {
self.ui.mic_enabled = !self.ui.mic_enabled;
}
if keybind::binding_pressed(ctx, &self.ui.kb_deafen) {
self.ui.deafen = !self.ui.deafen;
}
}
pub(crate) fn flush_encoded_voice(&mut self) {
let Some(pipeline) = self.audio.as_ref() else {
return;
};
while let Some(frame) = pipeline.poll_encoded() {
let net = self.net.clone();
tokio::spawn(async move {
net.send(NetCommand::SendVoice {
channel_id: frame.channel_id,
voice_seq: frame.voice_seq,
timestamp: frame.timestamp,
opus_data: frame.data,
})
.await;
});
}
}
}

View file

@ -1,138 +0,0 @@
use crate::state::{Channel, DmConversation, UiState};
use vnox_client::net::{NetCommand, NetEvent, NetHandle};
use super::into_msg;
pub(super) fn handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) {
match event {
NetEvent::ChannelState {
channel_id,
channel_name,
kind,
members,
..
} => {
ui.server_channel = Some(channel_id.clone());
let mems: Vec<String> = members
.iter()
.map(|m| {
ui.user_names.insert(m.user_id.clone(), m.nickname.clone());
m.nickname.clone()
})
.collect();
if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id) {
ch.members = mems;
} else {
ui.channels.push(Channel {
id: channel_id.clone(),
name: channel_name,
kind: kind.clone(),
members: mems,
});
}
if ui.active_channel.is_none() {
ui.active_channel = Some(channel_id.clone());
}
let net = net.clone();
let ch_id = channel_id;
tokio::spawn(async move {
net.send(NetCommand::ReadReceipt {
channel_id: ch_id,
last_read_message_id: uuid::Uuid::new_v4().to_string(),
})
.await;
});
}
NetEvent::UserJoin {
channel_id,
user_id,
nickname,
..
} => {
ui.user_names.insert(user_id, nickname.clone());
if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id)
&& !ch.members.contains(&nickname)
{
ch.members.push(nickname);
}
}
NetEvent::UserLeave {
channel_id,
user_id,
} => {
if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id) {
let nickname = ui.user_names.remove(&user_id);
ch.members.retain(|m| {
m != &user_id && nickname.as_deref().map(|n| m != n).unwrap_or(true)
});
}
}
NetEvent::ChannelCreated {
channel_id,
channel_name,
kind,
} => {
// Another session created a channel — add to local list if not present.
if !ui.channels.iter().any(|c| c.id == channel_id) {
ui.channels.push(Channel {
id: channel_id,
name: channel_name,
kind,
members: Vec::new(),
});
}
}
NetEvent::ChannelDeleted { channel_id } => {
ui.channels.retain(|c| c.id != channel_id);
// Clear active_channel if it pointed at the deleted channel.
if ui.active_channel.as_deref() == Some(&channel_id) {
ui.active_channel = ui.channels.first().map(|c| c.id.clone());
}
// Drop any cached messages for the deleted channel.
ui.messages.remove(&channel_id);
}
NetEvent::ChannelListEvent { channels } => {
// Replace local channel list with the server-authoritative one.
// Preserve `members` for channels we were already tracking.
let old: std::collections::HashMap<String, Channel> = std::mem::take(&mut ui.channels)
.into_iter()
.map(|c| (c.id.clone(), c))
.collect();
ui.channels = channels
.into_iter()
.map(|c| {
let members = old
.get(&c.channel_id)
.map(|o| o.members.clone())
.unwrap_or_default();
Channel {
id: c.channel_id,
name: c.channel_name,
kind: c.kind,
members,
}
})
.collect();
}
NetEvent::DmStart {
dm_id,
other_user_id,
other_nickname,
messages,
unread_count,
} => {
if !ui.dm_conversations.iter().any(|c| c.dm_id == dm_id) {
ui.dm_conversations.push(DmConversation {
dm_id: dm_id.clone(),
other_user_id,
other_nickname,
unread_count,
});
}
ui.dm_messages
.insert(dm_id.clone(), messages.into_iter().map(into_msg).collect());
ui.active_dm_id = Some(dm_id);
}
_ => {}
}
}

View file

@ -1,149 +0,0 @@
use crate::state::{ChatMessage, UiState};
use std::collections::HashMap;
use vnox_client::net::{NetEvent, NetHandle};
use super::into_msg;
pub(super) fn handle(ui: &mut UiState, _net: &NetHandle, event: NetEvent) {
match event {
NetEvent::ChatMessage {
message_id,
channel_id,
sender_id,
content,
timestamp,
reply_to,
..
} => {
ui.messages
.entry(channel_id)
.or_default()
.push(ChatMessage {
message_id,
sender_id,
content,
timestamp,
edited: false,
reactions: HashMap::new(),
reply_to,
});
ui.scroll_bottom = true;
}
NetEvent::ChatHistory {
channel_id,
messages,
} => {
ui.messages
.insert(channel_id, messages.into_iter().map(into_msg).collect());
ui.scroll_bottom = true;
}
NetEvent::Error { message, .. } => {
ui.status_msg = Some(format!("error: {message}"));
}
NetEvent::ReactionAdded {
channel_id,
message_id,
user_id,
emoji,
} => {
if let Some(msgs) = ui.messages.get_mut(&channel_id)
&& let Some(msg) = msgs.iter_mut().find(|m| m.message_id == message_id)
{
msg.reactions.entry(emoji).or_default().push(user_id);
}
}
NetEvent::ReactionRemoved {
channel_id,
message_id,
user_id,
emoji,
} => {
if let Some(msgs) = ui.messages.get_mut(&channel_id)
&& let Some(msg) = msgs.iter_mut().find(|m| m.message_id == message_id)
&& let Some(users) = msg.reactions.get_mut(&emoji)
{
users.retain(|u| u != &user_id);
if users.is_empty() {
msg.reactions.remove(&emoji);
}
}
}
NetEvent::MessageEdited {
channel_id,
message_id,
content,
..
} => {
if let Some(msgs) = ui.messages.get_mut(&channel_id)
&& let Some(msg) = msgs.iter_mut().find(|m| m.message_id == message_id)
{
msg.content = content;
msg.edited = true;
}
}
NetEvent::MessageDeleted {
channel_id,
message_id,
} => {
if let Some(msgs) = ui.messages.get_mut(&channel_id) {
msgs.retain(|m| m.message_id != message_id);
}
}
NetEvent::DmMessage {
dm_id,
sender_id,
content,
timestamp,
..
} => {
ui.dm_messages
.entry(dm_id.clone())
.or_default()
.push(ChatMessage {
message_id: uuid::Uuid::new_v4().to_string(),
sender_id,
content,
timestamp,
edited: false,
reactions: HashMap::new(),
reply_to: None,
});
if ui.active_dm_id.as_deref() != Some(&dm_id)
&& let Some(c) = ui.dm_conversations.iter_mut().find(|c| c.dm_id == dm_id)
{
c.unread_count += 1;
}
}
NetEvent::DmHistory { dm_id, messages } => {
ui.dm_messages
.insert(dm_id, messages.into_iter().map(into_msg).collect());
}
NetEvent::ReadReceiptBroadcast {
channel_id,
user_id,
last_read_message_id,
} => {
ui.read_receipts
.entry(channel_id)
.or_default()
.insert(user_id, last_read_message_id);
}
NetEvent::UserColor { user_id, color } => {
if let Some(c) = parse_hex_color(&color) {
ui.user_colors.insert(user_id, c);
}
}
_ => {}
}
}
fn parse_hex_color(hex: &str) -> Option<eframe::egui::Color32> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(eframe::egui::Color32::from_rgb(r, g, b))
}

View file

@ -1,28 +0,0 @@
mod channel;
mod messages;
use crate::state::{ChatMessage, UiState};
use std::collections::HashMap;
use vnox_client::net::{ChatMsg, NetEvent, NetHandle};
fn into_msg(m: ChatMsg) -> ChatMessage {
ChatMessage {
message_id: m.message_id,
sender_id: m.sender_id,
content: m.content,
timestamp: m.timestamp,
edited: false,
reactions: HashMap::new(),
reply_to: m.reply_to,
}
}
pub(crate) fn handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) {
match event {
NetEvent::ChannelState { .. }
| NetEvent::UserJoin { .. }
| NetEvent::UserLeave { .. }
| NetEvent::DmStart { .. } => channel::handle(ui, net, event),
e => messages::handle(ui, net, e),
}
}

View file

@ -1,169 +0,0 @@
mod chat;
mod social;
mod status;
use vnox_client::{
audio::IncomingVoice,
net::{NetCommand, NetEvent, NetHandle},
};
use crate::state::ConnState;
use super::VnoxApp;
impl VnoxApp {
pub(crate) fn poll_net(&mut self) {
while let Some(ev) = self.net.try_recv() {
match ev {
NetEvent::Connected {
session_id,
node_name,
restored,
private_mode,
} => self.on_connected(session_id, node_name, restored, private_mode),
NetEvent::Disconnected { reason, will_retry } => {
self.stop_voice();
status::handle_disconnected(&mut self.ui, &reason, will_retry);
}
NetEvent::Reconnecting {
attempt,
delay_secs,
} => {
self.stop_voice();
status::handle_reconnecting(&mut self.ui, attempt, delay_secs);
}
NetEvent::VoicePacket {
channel_id,
voice_seq,
timestamp,
opus_data,
sender_id,
..
} => {
// Track which remote user is speaking (Phase 1.3 attribution).
if !sender_id.is_empty() {
self.ui.last_remote_speaker_id = sender_id.clone();
self.ui.last_voice_packet_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
}
if !self.ui.deafen
&& let Some(pipeline) = self.audio.as_ref()
{
pipeline.push_voice(IncomingVoice {
voice_seq,
timestamp,
channel_id,
opus_data,
});
}
}
event => process_net_event(&mut self.ui, &self.net, event),
}
self.sync_voice_audio();
self.flush_encoded_voice();
}
// Refresh speaking indicators from the audio pipeline.
if let Some(pipeline) = self.audio.as_ref() {
self.ui.local_speaking = pipeline.local_speaking();
self.ui.remote_speaking = pipeline.remote_speaking(500);
} else {
self.ui.local_speaking = false;
self.ui.remote_speaking = false;
}
// Decay remote speaker id if no packet arrived in 700 ms.
if !self.ui.remote_speaking {
self.ui.last_remote_speaker_id.clear();
}
}
pub(crate) fn on_connected(
&mut self,
session_id: String,
node_name: String,
restored: bool,
private_mode: bool,
) {
self.ui.conn = ConnState::Connected {
session_id,
node_name,
};
self.ui.private_mode = private_mode;
if restored {
self.ui.status_msg = Some("reconnected".into());
if let Some(ch) = self.ui.active_channel.clone() {
let net = self.net.clone();
tokio::spawn(async move {
net.send(NetCommand::JoinChannel { channel_id: ch }).await;
});
}
} else {
self.ui.seed_default_channels();
self.ui.active_channel = Some("general".into());
self.ui.status_msg = None;
let net = self.net.clone();
tokio::spawn(async move {
net.send(NetCommand::JoinChannel {
channel_id: "general".into(),
})
.await;
});
}
}
}
pub(crate) fn process_net_event(ui: &mut crate::state::UiState, net: &NetHandle, event: NetEvent) {
match &event {
NetEvent::ChannelState { .. }
| NetEvent::ChannelCreated { .. }
| NetEvent::ChannelDeleted { .. }
| NetEvent::ChannelListEvent { .. }
| NetEvent::ChatMessage { .. }
| NetEvent::ChatHistory { .. }
| NetEvent::UserJoin { .. }
| NetEvent::UserLeave { .. }
| NetEvent::Error { .. }
| NetEvent::ReactionAdded { .. }
| NetEvent::ReactionRemoved { .. }
| NetEvent::MessageEdited { .. }
| NetEvent::MessageDeleted { .. }
| NetEvent::DmStart { .. }
| NetEvent::DmMessage { .. }
| NetEvent::DmHistory { .. }
| NetEvent::ReadReceiptBroadcast { .. }
| NetEvent::UserColor { .. } => chat::handle(ui, net, event),
NetEvent::GuildList { .. }
| NetEvent::GuildCreated { .. }
| NetEvent::GuildDeleted { .. }
| NetEvent::GuildMemberJoined { .. }
| NetEvent::GuildMemberLeft { .. }
| NetEvent::GuildMemberKicked { .. }
| NetEvent::InviteCreated { .. }
| NetEvent::InviteAccepted { .. }
| NetEvent::InviteDeleted { .. }
| NetEvent::RoleCreated { .. }
| NetEvent::RoleDeleted { .. }
| NetEvent::GuildAuditLog { .. }
| NetEvent::GuildMemberList { .. }
| NetEvent::GuildRoleList { .. }
| NetEvent::FriendList { .. }
| NetEvent::FriendRequested { .. }
| NetEvent::FriendAccepted { .. }
| NetEvent::FriendRemoved { .. }
| NetEvent::BlockList { .. }
| NetEvent::BlockedUser { .. }
| NetEvent::UnblockedUser { .. } => social::handle(ui, event),
NetEvent::LatencyUpdate { .. }
| NetEvent::TypingStart { .. }
| NetEvent::PresenceSync { .. }
| NetEvent::PresenceUpdated { .. } => status::handle(ui, net, event),
NetEvent::Connected { .. }
| NetEvent::Disconnected { .. }
| NetEvent::Reconnecting { .. }
| NetEvent::VoicePacket { .. } => {}
}
}

View file

@ -1,148 +0,0 @@
use vnox_client::net::NetEvent;
use crate::state::{FriendState, GuildState};
use crate::state::UiState;
pub(crate) fn handle(ui: &mut UiState, event: NetEvent) {
match event {
NetEvent::GuildAuditLog { guild_id, entries } => {
ui.audit_log_entries = entries;
ui.audit_log_guild_id = Some(guild_id);
}
NetEvent::GuildMemberList { guild_id, members } => {
ui.guild_member_list_guild_id = Some(guild_id);
ui.guild_member_list = members;
}
NetEvent::GuildRoleList { guild_id, roles } => {
ui.guild_role_list_guild_id = Some(guild_id);
ui.guild_role_list = roles;
}
NetEvent::BlockList { blocked } => {
ui.blocked_users = blocked;
}
NetEvent::BlockedUser { user_id } => {
if !ui.blocked_users.contains(&user_id) {
ui.blocked_users.push(user_id);
}
}
NetEvent::UnblockedUser { user_id } => {
ui.blocked_users.retain(|u| u != &user_id);
}
NetEvent::GuildList { guilds } => {
ui.guilds = guilds
.into_iter()
.map(|g| GuildState {
guild_id: g.guild_id,
name: g.name,
owner_id: g.owner_id,
member_count: g.member_count,
})
.collect();
}
NetEvent::GuildCreated {
guild_id,
name,
owner_id,
} => {
ui.guilds.push(GuildState {
guild_id: guild_id.clone(),
name,
owner_id,
member_count: 1,
});
ui.active_guild_id = Some(guild_id);
}
NetEvent::GuildDeleted { guild_id } => {
ui.guilds.retain(|g| g.guild_id != guild_id);
if ui.active_guild_id.as_deref() == Some(&guild_id) {
ui.active_guild_id = None;
}
}
NetEvent::GuildMemberJoined {
guild_id,
user_id: _,
nickname: _,
} => {
if let Some(g) = ui.guilds.iter_mut().find(|g| g.guild_id == guild_id) {
g.member_count += 1;
}
}
NetEvent::GuildMemberLeft {
guild_id,
user_id: _,
} => {
if let Some(g) = ui.guilds.iter_mut().find(|g| g.guild_id == guild_id) {
g.member_count = g.member_count.saturating_sub(1);
}
}
NetEvent::GuildMemberKicked {
guild_id,
user_id: _,
} => {
if let Some(g) = ui.guilds.iter_mut().find(|g| g.guild_id == guild_id) {
g.member_count = g.member_count.saturating_sub(1);
}
}
NetEvent::InviteCreated { .. } => {
// UI will fetch invite list separately if needed
}
NetEvent::InviteAccepted {
guild_id,
guild_name,
} if !ui.guilds.iter().any(|g| g.guild_id == guild_id) => {
ui.guilds.push(GuildState {
guild_id,
name: guild_name,
owner_id: String::new(),
member_count: 1,
});
}
NetEvent::InviteDeleted {
guild_id: _,
invite_id: _,
} => {}
NetEvent::RoleCreated { .. } => {}
NetEvent::RoleDeleted { .. } => {}
NetEvent::FriendList { friends } => {
ui.friends = friends
.into_iter()
.map(|f| FriendState {
user_id: f.user_id,
nickname: f.nickname,
status: f.status,
since: f.since,
})
.collect();
}
NetEvent::FriendRequested { user_id, nickname } => {
ui.pending_friend_requests.push(FriendState {
user_id,
nickname,
status: "offline".into(),
since: 0,
});
}
NetEvent::FriendAccepted { user_id, nickname } => {
// Remove from pending requests
ui.pending_friend_requests.retain(|f| f.user_id != user_id);
// Avoid duplicate entries in friends list
if !ui.friends.iter().any(|f| f.user_id == user_id) {
ui.friends.push(FriendState {
user_id,
nickname,
status: "online".into(),
since: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0),
});
}
}
NetEvent::FriendRemoved { user_id } => {
ui.friends.retain(|f| f.user_id != user_id);
ui.pending_friend_requests.retain(|f| f.user_id != user_id);
}
_ => {}
}
}

View file

@ -1,69 +0,0 @@
use vnox_client::net::{NetEvent, NetHandle, PresenceInfo};
use crate::state::ConnState;
use crate::state::UiState;
pub(crate) fn handle_disconnected(ui: &mut UiState, reason: &str, will_retry: bool) {
if will_retry {
ui.status_msg = Some(format!("connection lost: {reason}"));
} else {
ui.conn = ConnState::Disconnected;
ui.channels.clear();
ui.active_channel = None;
ui.user_names.clear();
ui.server_channel = None;
ui.rtt_ms = None;
ui.status_msg = Some(format!("disconnected: {reason}"));
}
ui.dm_conversations.clear();
ui.dm_messages.clear();
ui.active_dm_id = None;
ui.new_dm_input = None;
ui.private_mode = false;
}
pub(crate) fn handle_reconnecting(ui: &mut UiState, attempt: u32, delay_secs: u64) {
ui.conn = ConnState::Reconnecting { attempt };
ui.status_msg = Some(format!("reconnecting in {delay_secs}s (attempt {attempt})"));
}
pub(crate) fn handle(ui: &mut UiState, _net: &NetHandle, event: NetEvent) {
match event {
NetEvent::LatencyUpdate { rtt_ms } => {
ui.rtt_ms = Some(rtt_ms);
}
NetEvent::TypingStart {
user_id,
nickname,
channel_id,
} => {
let entry = ui.typing_users.entry(channel_id).or_default();
entry.retain(|(u, _)| u != &user_id);
entry.push((nickname, std::time::Instant::now()));
}
NetEvent::PresenceSync { presences } => {
for p in presences {
ui.presences.insert(p.user_id.clone(), p);
}
}
NetEvent::PresenceUpdated {
user_id,
status,
custom_status,
activity,
} => {
ui.presences.insert(
user_id.clone(),
PresenceInfo {
user_id,
status,
custom_status,
activity,
activity_text: None,
},
);
}
_ => {}
}
}

View file

@ -1,50 +0,0 @@
use tracing::{info, warn};
use vnox_client::audio::{self, PipelineConfig};
use vnox_client::net::voice;
use super::VnoxApp;
impl VnoxApp {
pub(crate) fn pipeline_config(&self, channel_id: &str) -> PipelineConfig {
PipelineConfig {
channel_key: voice::channel_key(channel_id),
mic_device_index: self.ui.mic_device,
output_device_index: self.ui.output_device,
voice_bitrate_kbps: self.ui.voice_bitrate,
packet_interval: self.ui.packet_interval,
}
}
pub(crate) fn start_voice(&mut self, channel_id: &str) {
let cfg = self.pipeline_config(channel_id);
if self.voice_channel.as_deref() == Some(channel_id)
&& self.audio.is_some()
&& self.last_pipeline_config.as_ref() == Some(&cfg)
{
return;
}
self.audio = None;
match audio::start(cfg.clone()) {
Ok(pipeline) => {
info!("voice active on channel {channel_id}");
self.audio = Some(pipeline);
self.voice_channel = Some(channel_id.to_string());
self.last_pipeline_config = Some(cfg);
self.ui.voice = crate::state::VoiceUiState::Active {
channel: channel_id.to_string(),
};
if self.ui.voice_joined_at.is_none() {
self.ui.voice_joined_at = Some(std::time::Instant::now());
}
self.ui.status_msg = None;
}
Err(e) => {
warn!("audio start failed: {e}");
self.voice_channel = None;
self.last_pipeline_config = None;
self.ui.voice = crate::state::VoiceUiState::Error(e.to_string());
self.ui.status_msg = Some(format!("microphone error: {e}"));
}
}
}
}

267
src/ui/bridge.rs Normal file
View file

@ -0,0 +1,267 @@
use std::rc::Rc;
use slint::{SharedString, VecModel};
use crate::ui::state::{ConnState, UiState};
type MainWindow = crate::ui::slint::MainWindow;
type BookmarkItem = crate::ui::slint::BookmarkItem;
type ChannelItem = crate::ui::slint::ChannelItem;
type GuildItem = crate::ui::slint::GuildItem;
type MessageItem = crate::ui::slint::MessageItem;
type MemberItem = crate::ui::slint::MemberItem;
type FriendItem = crate::ui::slint::FriendItem;
type VoiceMemberItem = crate::ui::slint::VoiceMemberItem;
fn fmt_ts(ts: i64) -> String {
let secs = (ts / 1000).abs() % 86400;
format!("{:02}:{:02}", secs / 3600, (secs % 3600) / 60)
}
fn set_messages(window: &MainWindow, msgs: &[crate::ui::state::ChatMessage], own_user_id: &str) {
let items: Vec<MessageItem> = msgs
.iter()
.map(|m| MessageItem {
message_id: SharedString::from(m.message_id.as_str()),
sender_id: SharedString::from(m.sender_id.as_str()),
sender_name: SharedString::from(m.sender_id.as_str()),
content: SharedString::from(m.content.as_str()),
timestamp: SharedString::from(fmt_ts(m.timestamp)),
is_own: m.sender_id == own_user_id,
edited: m.edited,
reply_to: SharedString::from(m.reply_to.as_deref().unwrap_or("")),
})
.collect();
window.set_messages(Rc::new(VecModel::from(items)).into());
}
fn set_members_state(window: &MainWindow, state: &UiState, ch_id: &str) {
if let Some(ch) = state.channels.iter().find(|c| c.id == ch_id) {
let items: Vec<MemberItem> = ch
.members
.iter()
.map(|nick| {
let user_id = state
.user_names
.iter()
.find(|(_, n)| *n == nick)
.map(|(id, _)| id.clone())
.unwrap_or_else(|| nick.clone());
let status = state
.presences
.get(&user_id)
.map(|p| p.status.as_str())
.unwrap_or("offline");
MemberItem {
user_id: SharedString::from(user_id),
nickname: SharedString::from(nick.as_str()),
status: SharedString::from(status),
}
})
.collect();
window.set_members(Rc::new(VecModel::from(items)).into());
}
}
fn sync_sidebar(window: &MainWindow, state: &UiState) {
let channels: Vec<ChannelItem> = state
.channels
.iter()
.map(|c| ChannelItem {
channel_id: SharedString::from(c.id.as_str()),
name: SharedString::from(c.name.as_str()),
kind: SharedString::from(c.kind.as_str()),
})
.collect();
window.set_channels(Rc::new(VecModel::from(channels)).into());
let guilds: Vec<GuildItem> = state
.guilds
.iter()
.map(|g| GuildItem {
guild_id: SharedString::from(g.guild_id.as_str()),
name: SharedString::from(g.name.as_str()),
})
.collect();
window.set_guilds(Rc::new(VecModel::from(guilds)).into());
let dms: Vec<SharedString> = state
.dm_conversations
.iter()
.map(|c| SharedString::from(c.other_nickname.as_str()))
.collect();
window.set_dm_conversations(Rc::new(VecModel::from(dms)).into());
window.set_username(SharedString::from("user"));
window.set_user_status(SharedString::from("online"));
window.set_active_channel_id(SharedString::from(
state.active_channel.as_deref().unwrap_or(""),
));
window.set_active_guild_idx(state.active_guild_id.clone().map_or(-1, |id| {
state
.guilds
.iter()
.position(|g| g.guild_id == id)
.map_or(-1, |i| i as i32)
}));
}
fn sync_chat(window: &MainWindow, state: &UiState, own_user_id: &str) {
if let Some(ch_id) = &state.active_channel {
if let Some(ch) = state.channels.iter().find(|c| &c.id == ch_id) {
window.set_active_channel_name(SharedString::from(ch.name.as_str()));
if ch.kind == "voice" {
window.set_show_chat(false);
window.set_show_voice(true);
window.set_show_friends(false);
} else {
window.set_show_chat(true);
window.set_show_voice(false);
window.set_show_friends(false);
if let Some(msgs) = state.messages.get(ch_id) {
set_messages(window, msgs, own_user_id);
} else {
window.set_messages(Rc::new(VecModel::from(vec![])).into());
}
set_members_state(window, state, ch_id);
}
}
} else {
window.set_show_chat(false);
window.set_show_voice(false);
window.set_show_friends(true);
}
window.set_input_text(SharedString::from(state.chat_input.as_str()));
}
fn sync_friends(window: &MainWindow, state: &UiState) {
let friends: Vec<FriendItem> = state
.friends
.iter()
.map(|f| FriendItem {
user_id: SharedString::from(f.user_id.as_str()),
nickname: SharedString::from(f.nickname.as_str()),
status: SharedString::from(
state
.presences
.get(&f.user_id)
.map(|p| p.status.as_str())
.unwrap_or("offline"),
),
})
.collect();
window.set_friends(Rc::new(VecModel::from(friends)).into());
let pending: Vec<FriendItem> = state
.pending_friend_requests
.iter()
.map(|f| FriendItem {
user_id: SharedString::from(f.user_id.as_str()),
nickname: SharedString::from(f.nickname.as_str()),
status: SharedString::from("pending"),
})
.collect();
window.set_pending_requests(Rc::new(VecModel::from(pending)).into());
let blocked: Vec<SharedString> = state
.blocked_users
.iter()
.map(|u| SharedString::from(u.as_str()))
.collect();
window.set_blocked_users(Rc::new(VecModel::from(blocked)).into());
window.set_friends_active_tab(SharedString::from(state.friends_tab.as_str()));
}
pub fn sync_voice(window: &MainWindow, state: &UiState) {
let members: Vec<VoiceMemberItem> = state
.channels
.iter()
.filter(|c| {
matches!(
&state.voice,
crate::ui::state::VoiceUiState::Active { channel } if channel == &c.id
)
})
.flat_map(|c| c.members.iter())
.map(|nick| {
let user_id = state
.user_names
.iter()
.find(|(_, n)| *n == nick)
.map(|(id, _)| id.clone())
.unwrap_or_else(|| nick.clone());
VoiceMemberItem {
user_id: SharedString::from(user_id.clone()),
nickname: SharedString::from(nick.as_str()),
speaking: state.last_remote_speaker_id == user_id,
}
})
.collect();
window.set_voice_members(Rc::new(VecModel::from(members)).into());
window.set_mic_enabled(state.mic_enabled);
window.set_deafened(state.deafen);
if let crate::ui::state::VoiceUiState::Active { channel } = &state.voice {
if let Some(ch) = state.channels.iter().find(|c| &c.id == channel) {
window.set_voice_channel_name(SharedString::from(ch.name.as_str()));
}
}
let banner = match (state.local_speaking, state.remote_speaking) {
(true, true) => "you and others are talking".to_string(),
(true, false) => "you are talking".to_string(),
(false, true) => format!(
"{} is talking",
state.nick_for(&state.last_remote_speaker_id)
),
_ => String::new(),
};
window.set_activity_banner(SharedString::from(banner));
}
pub fn sync_connect(window: &MainWindow, state: &UiState) {
let bookmarks: Vec<BookmarkItem> = state
.bookmarks
.iter()
.map(|bm| BookmarkItem {
name: SharedString::from(bm.label.as_str()),
address: SharedString::from(bm.address.as_str()),
})
.collect();
window.set_bookmarks(Rc::new(VecModel::from(bookmarks)).into());
window.set_address_input(SharedString::from(state.connect_input.as_str()));
window.set_status_text(SharedString::from(
state.status_msg.as_deref().unwrap_or(""),
));
window.set_connecting(matches!(
state.conn,
ConnState::Connecting | ConnState::Reconnecting { .. }
));
window.set_auto_reconnect(state.auto_reconnect);
if let ConnState::Connected { ref node_name, .. } = state.conn {
window.set_node_name(SharedString::from(node_name.as_str()));
} else {
window.set_node_name(SharedString::from(""));
}
}
pub fn sync_settings(window: &MainWindow, state: &UiState) {
window.set_identity_pubkey(SharedString::from(""));
window.set_vault_enabled(false);
window.set_voice_input_device(SharedString::from(""));
window.set_voice_output_device(SharedString::from(""));
window.set_voice_bitrate(state.voice_bitrate);
window.set_vad_mode(state.vad_mode as i32);
window.set_volume(state.output_volume);
window.set_active_tab(SharedString::from("audio"));
}
pub fn sync_ui(window: &MainWindow, state: &UiState, own_user_id: &str) {
sync_sidebar(window, state);
sync_chat(window, state, own_user_id);
sync_friends(window, state);
sync_voice(window, state);
sync_connect(window, state);
sync_settings(window, state);
}

View file

@ -1,165 +0,0 @@
use super::super::text::message_row;
use crate::state::{ChatMessage, UiState};
use crate::theme as t;
use eframe::egui::{self, Context, FontId, Frame, RichText, ScrollArea, TextEdit};
use std::collections::HashMap;
use vnox_client::{
identity::Identity,
net::{NetCommand, NetHandle},
};
pub(super) fn search_bar(
ui: &mut egui::Ui,
s: &mut UiState,
net: &NetHandle,
dm_id: &str,
_h: f32,
) {
Frame::NONE
.inner_margin(egui::Margin::symmetric(12, 4))
.show(ui, |ui| {
ui.horizontal(|ui| {
let resp = ui.add_sized(
egui::vec2(ui.available_width(), 20.0),
TextEdit::singleline(&mut s.dm_search_query)
.font(FontId::proportional(12.0))
.text_color(t::TEXT_PRIMARY)
.hint_text(RichText::new("search…").color(t::TEXT_DISABLED))
.frame(true),
);
if resp.changed() {
let query = s.dm_search_query.trim().to_string();
let net = net.clone();
let id = dm_id.to_string();
tokio::spawn(async move {
net.send(NetCommand::DmSearch { dm_id: id, query }).await;
});
}
});
});
}
pub(super) fn show(
ui: &mut egui::Ui,
s: &mut UiState,
net: &NetHandle,
identity: &Identity,
dm_id: &str,
max_h: f32,
) {
let msgs = s.dm_messages.get(dm_id).cloned().unwrap_or_default();
ScrollArea::vertical()
.max_height(max_h)
.auto_shrink([false; 2])
.stick_to_bottom(s.scroll_bottom)
.show(ui, |ui| {
ui.add_space(12.0);
if msgs.is_empty() {
ui.label(
RichText::new("no messages yet")
.font(FontId::proportional(12.0))
.color(t::TEXT_DISABLED),
);
} else {
ui.label(
RichText::new("today")
.font(FontId::proportional(11.0))
.color(t::TEXT_DISABLED),
);
ui.add_space(8.0);
let mut prev: Option<String> = None;
for msg in &msgs {
let group_start = prev.as_deref() != Some(&msg.sender_id);
message_row(ui, s, net, identity, msg, dm_id, group_start);
prev = Some(msg.sender_id.clone());
}
}
ui.add_space(8.0);
});
s.scroll_bottom = false;
}
pub(super) fn input_bar(
ui: &mut egui::Ui,
ctx: &Context,
s: &mut UiState,
net: &NetHandle,
_identity: &Identity,
dm_id: &str,
) {
let r = ui.available_rect_before_wrap();
ui.painter().hline(
r.x_range(),
r.top(),
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
Frame::NONE
.fill(t::BG_SURFACE)
.inner_margin(egui::Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.horizontal(|ui| {
let input = egui::TextEdit::singleline(&mut s.chat_input)
.font(FontId::proportional(13.0))
.text_color(t::TEXT_PRIMARY)
.hint_text(RichText::new("message…").color(t::TEXT_DISABLED))
.frame(false)
.desired_width(ui.available_width() - 44.0);
let enter = Frame::NONE
.fill(t::BG_INTERACTIVE)
.corner_radius(t::R_MD)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.inner_margin(egui::Margin::symmetric(12, 6))
.show(ui, |ui| ui.add(input))
.inner;
let submit = (enter.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|| ui
.add(
egui::Button::new(RichText::new("→").color(t::ACCENT).size(14.0))
.fill(t::ACCENT_10)
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
.corner_radius(t::R_MD)
.min_size(egui::vec2(32.0, 32.0)),
)
.clicked();
if submit && !s.chat_input.trim().is_empty() {
send_dm(s, net, dm_id);
ctx.request_repaint();
}
});
});
}
fn send_dm(s: &mut UiState, net: &NetHandle, dm_id: &str) {
let content = s.chat_input.trim().to_string();
if content.is_empty() {
return;
}
s.chat_input.clear();
s.dm_messages
.entry(dm_id.to_string())
.or_default()
.push(ChatMessage {
message_id: uuid::Uuid::new_v4().to_string(),
sender_id: String::new(),
content: content.clone(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64,
edited: false,
reactions: HashMap::new(),
reply_to: None,
});
s.scroll_bottom = true;
let net = net.clone();
let id = dm_id.to_string();
tokio::spawn(async move {
net.send(NetCommand::DmSend { dm_id: id, content }).await;
});
}

View file

@ -1,71 +0,0 @@
mod messages;
use eframe::egui::{self, Context, FontId, Frame, RichText};
use vnox_client::{identity::Identity, net::NetHandle};
use crate::state::UiState;
use crate::theme as t;
pub(crate) fn dm_chat(
ui: &mut egui::Ui,
ctx: &Context,
s: &mut UiState,
identity: &Identity,
net: &NetHandle,
dm_id: &str,
) {
let conv = s
.dm_conversations
.iter()
.find(|c| c.dm_id == dm_id)
.cloned();
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
Frame::NONE
.fill(t::BG_SURFACE)
.inner_margin(egui::Margin::symmetric(16, 0))
.show(ui, |ui| {
ui.set_height(40.0);
ui.horizontal_centered(|ui| {
ui.label(RichText::new("@").color(t::TEXT_MUTED).size(16.0));
ui.add_space(4.0);
let name = conv
.as_ref()
.map(|c| c.other_nickname.as_str())
.unwrap_or("?");
ui.label(
RichText::new(name)
.font(FontId::proportional(14.0))
.color(t::TEXT_PRIMARY)
.strong(),
);
});
});
let r = ui.max_rect();
ui.painter().hline(
r.x_range(),
r.top() + 40.0,
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
let search_h = 32.0;
let input_h = 52.0;
messages::search_bar(ui, s, net, dm_id, search_h);
ui.painter().hline(
r.x_range(),
r.top() + 40.0 + search_h,
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
messages::show(
ui,
s,
net,
identity,
dm_id,
ui.available_height() - input_h - search_h,
);
messages::input_bar(ui, ctx, s, net, identity, dm_id);
});
}

View file

@ -1,270 +0,0 @@
use super::{muted_label, pending, section_header, status_color, status_label};
use crate::state::{FriendState, UiState};
use crate::theme as t;
use eframe::egui::{self, FontId, Frame, RichText, ScrollArea};
use vnox_client::net::{NetCommand, NetHandle};
pub(super) fn render_content(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
ScrollArea::vertical()
.auto_shrink([false, true])
.show(ui, |ui| {
ui.add_space(4.0);
match s.friends_tab.as_str() {
"online" => render_online(ui, s, net),
"pending" => render_pending(ui, s, net),
"blocked" => render_blocked(ui, s, net),
_ => render_all(ui, s, net),
}
});
}
fn render_online(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
section_header(ui, "ONLINE");
let online: Vec<&FriendState> = s
.friends
.iter()
.filter(|f| matches!(f.status.as_str(), "online" | "idle" | "dnd"))
.collect();
if online.is_empty() {
muted_label(ui, "no friends online");
} else {
for f in &online {
friend_row(ui, f, net);
}
}
}
fn render_all(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
section_header(ui, "ONLINE");
let online: Vec<&FriendState> = s
.friends
.iter()
.filter(|f| matches!(f.status.as_str(), "online" | "idle" | "dnd"))
.collect();
if online.is_empty() {
muted_label(ui, "no friends online");
} else {
for f in &online {
friend_row(ui, f, net);
}
}
ui.add_space(16.0);
section_header(ui, "OFFLINE");
let offline: Vec<&FriendState> = s
.friends
.iter()
.filter(|f| !matches!(f.status.as_str(), "online" | "idle" | "dnd"))
.collect();
if offline.is_empty() {
if s.friends.is_empty() {
muted_label(ui, "no friends yet — add some!");
} else {
muted_label(ui, "nobody is offline");
}
} else {
for f in &offline {
friend_row(ui, f, net);
}
}
}
fn render_pending(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
section_header(ui, "PENDING — INCOMING");
if s.pending_friend_requests.is_empty() {
muted_label(ui, "no pending requests");
} else {
for req in &s.pending_friend_requests {
pending::pending_row(ui, req, net);
}
}
}
fn render_blocked(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
// Block-user input row
Frame::NONE
.inner_margin(egui::Margin::symmetric(12, 4))
.show(ui, |ui| {
ui.horizontal(|ui| {
let mut buf = s.block_input.clone().unwrap_or_default();
let resp = ui.add(
egui::TextEdit::singleline(&mut buf)
.font(egui::FontId::monospace(11.0))
.hint_text("user id to block")
.desired_width(140.0)
.text_color(t::TEXT_PRIMARY),
);
let mut trigger = false;
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
trigger = true;
}
if ui
.add(
egui::Button::new(
RichText::new("Block")
.font(FontId::proportional(10.0))
.color(t::ERROR),
)
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_SM)
.min_size(egui::vec2(50.0, 18.0)),
)
.clicked()
{
trigger = true;
}
s.block_input = if buf.is_empty() { None } else { Some(buf) };
if trigger
&& let Some(target) = s.block_input.clone().filter(|s| !s.trim().is_empty())
{
let net = net.clone();
let target = target.trim().to_string();
tokio::spawn(async move {
net.send(NetCommand::BlockUser { user_id: target }).await;
});
s.block_input = None;
}
});
});
ui.add_space(8.0);
section_header(ui, "BLOCKED");
if s.blocked_users.is_empty() {
muted_label(ui, "no blocked users — enter a user ID above to block");
return;
}
// Fetch on first open of this tab.
if s.block_list_fetched_at.is_none() {
s.block_list_fetched_at = Some(std::time::Instant::now());
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::BlockList).await;
});
}
for uid in s.blocked_users.clone() {
Frame::NONE
.fill(t::BG_SURFACE)
.corner_radius(t::R_MD)
.inner_margin(egui::Margin::symmetric(12, 6))
.show(ui, |ui| {
ui.horizontal(|ui| {
let init: String = uid.chars().next().unwrap_or('?').to_uppercase().to_string();
t::avatar(ui, &init, t::TEXT_DISABLED, 28.0);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.label(
RichText::new(&uid)
.font(FontId::monospace(11.0))
.color(t::TEXT_PRIMARY),
);
ui.label(
RichText::new("blocked")
.font(FontId::proportional(10.0))
.color(t::TEXT_DISABLED),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(
RichText::new("Unblock")
.font(FontId::proportional(11.0))
.color(t::SUCCESS),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.on_hover_text("unblock this user")
.clicked()
{
let net = net.clone();
let uidc = uid.clone();
tokio::spawn(async move {
net.send(NetCommand::UnblockUser { user_id: uidc }).await;
});
}
});
});
});
}
}
fn friend_row(ui: &mut egui::Ui, f: &FriendState, net: &NetHandle) {
Frame::NONE
.fill(t::BG_SURFACE)
.corner_radius(t::R_MD)
.inner_margin(egui::Margin::symmetric(12, 6))
.show(ui, |ui| {
ui.horizontal(|ui| {
let init = f
.nickname
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string();
t::avatar(ui, &init, status_color(&f.status), 28.0);
ui.add_space(8.0);
let (dot_r, _) =
ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
ui.painter()
.circle_filled(dot_r.center(), 4.0, status_color(&f.status));
ui.add_space(6.0);
ui.vertical(|ui| {
ui.label(
RichText::new(&f.nickname)
.font(FontId::proportional(13.0))
.color(t::TEXT_PRIMARY),
);
ui.label(
RichText::new(status_label(&f.status))
.font(FontId::proportional(10.0))
.color(t::TEXT_DISABLED),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if t::icon_btn_drawn(
ui,
crate::theme::icons::envelope,
"send message",
None,
18.0,
)
.clicked()
{
let net = net.clone();
let uid = f.user_id.clone();
tokio::spawn(async move {
net.send(NetCommand::DmStart {
target_user_id: uid,
})
.await;
});
}
if t::icon_btn_drawn(
ui,
crate::theme::icons::close,
"remove friend",
None,
16.0,
)
.clicked()
{
let net = net.clone();
let uid = f.user_id.clone();
tokio::spawn(async move {
net.send(NetCommand::FriendRemove { user_id: uid }).await;
});
}
});
});
});
}

View file

@ -1,209 +0,0 @@
mod list;
mod pending;
use eframe::egui::{self, FontId, Frame, RichText};
use vnox_client::net::{NetCommand, NetHandle};
use crate::state::UiState;
use crate::theme as t;
pub fn show(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
Frame::NONE
.fill(t::BG_SURFACE)
.inner_margin(egui::Margin::symmetric(16, 0))
.show(ui, |ui| {
ui.set_height(44.0);
ui.horizontal_centered(|ui| {
ui.label(
RichText::new("Friends")
.font(FontId::proportional(16.0))
.color(t::TEXT_PRIMARY)
.strong(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let add_btn = egui::Button::new(
RichText::new("Add Friend")
.font(FontId::proportional(11.0))
.color(t::ACCENT_SOFT),
)
.fill(t::ACCENT)
.stroke(egui::Stroke::NONE)
.corner_radius(t::R_SM)
.min_size(egui::vec2(80.0, 26.0));
if ui.add(add_btn).clicked() {
s.add_friend_open = true;
s.add_friend_input.clear();
}
});
});
});
let sep = ui.max_rect();
ui.painter().hline(
sep.x_range(),
sep.top() + 44.0,
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
ui.add_space(8.0);
// Pending count badge for the Pending tab
let pending_count = s.pending_friend_requests.len();
ui.horizontal(|ui| {
ui.add_space(16.0);
ui.spacing_mut().item_spacing.x = 4.0;
for (tab_key, label) in [
("online", "Online"),
("all", "All"),
("pending", "Pending"),
("blocked", "Blocked"),
] {
let selected = s.friends_tab == tab_key;
let display = if tab_key == "pending" && pending_count > 0 {
format!("{label} ({pending_count})")
} else {
label.to_string()
};
let btn = egui::Button::new(
RichText::new(display)
.font(FontId::proportional(11.0))
.color(if selected {
t::TEXT_PRIMARY
} else {
t::TEXT_MUTED
}),
)
.fill(if selected {
t::BG_ELEVATED
} else {
egui::Color32::TRANSPARENT
})
.stroke(egui::Stroke::NONE)
.corner_radius(t::R_SM);
if ui.add(btn).clicked() {
s.friends_tab = tab_key.to_string();
}
}
});
ui.add_space(8.0);
list::render_content(ui, s, net);
});
if s.add_friend_open {
show_add_friend_popup(ui.ctx(), s, net);
}
}
fn show_add_friend_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) {
egui::Window::new("Add Friend")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(320.0);
ui.label(
RichText::new("Enter a user ID to send a friend request.")
.font(FontId::proportional(12.0))
.color(t::TEXT_MUTED),
);
ui.add_space(8.0);
let resp = ui.add(
egui::TextEdit::singleline(&mut s.add_friend_input)
.font(FontId::monospace(13.0))
.text_color(t::TEXT_PRIMARY)
.hint_text(RichText::new("user id (pubkey hex)").color(t::TEXT_DISABLED))
.desired_width(280.0),
);
ui.add_space(12.0);
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new(
RichText::new("Cancel")
.font(FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.add_friend_open = false;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let send_btn = egui::Button::new(
RichText::new("Send Request")
.font(FontId::proportional(12.0))
.color(egui::Color32::WHITE),
)
.fill(t::ACCENT)
.stroke(egui::Stroke::NONE)
.corner_radius(t::R_SM)
.min_size(egui::vec2(110.0, 28.0));
if ui.add(send_btn).clicked() && !s.add_friend_input.trim().is_empty() {
let net = net.clone();
let target = s.add_friend_input.trim().to_string();
tokio::spawn(async move {
net.send(NetCommand::FriendRequest {
target_user_id: target,
})
.await;
});
s.add_friend_open = false;
}
});
});
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
s.add_friend_open = false;
}
});
}
pub(super) fn section_header(ui: &mut egui::Ui, label: &str) {
ui.add_space(4.0);
ui.label(
RichText::new(label)
.font(FontId::proportional(10.0))
.color(t::TEXT_MUTED)
.strong(),
);
ui.add_space(2.0);
}
pub(super) fn muted_label(ui: &mut egui::Ui, text: &str) {
ui.add_space(4.0);
ui.label(
RichText::new(text)
.font(FontId::proportional(12.0))
.color(t::TEXT_DISABLED),
);
}
pub(super) fn status_color(status: &str) -> egui::Color32 {
match status {
"online" => t::SUCCESS,
"idle" => t::WARNING,
"dnd" => t::ERROR,
_ => t::TEXT_DISABLED,
}
}
pub(super) fn status_label(status: &str) -> &str {
match status {
"online" => "online",
"idle" => "idle",
"dnd" => "do not disturb",
"offline" => "offline",
_ => status,
}
}

View file

@ -1,62 +0,0 @@
use eframe::egui::{self, FontId, Frame, RichText};
use vnox_client::net::{NetCommand, NetHandle};
use crate::state::FriendState;
use crate::theme as t;
pub(super) fn pending_row(ui: &mut egui::Ui, f: &FriendState, net: &NetHandle) {
Frame::NONE
.fill(t::BG_SURFACE)
.corner_radius(t::R_MD)
.inner_margin(egui::Margin::symmetric(12, 6))
.show(ui, |ui| {
ui.horizontal(|ui| {
let init = f
.nickname
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string();
t::avatar(ui, &init, t::WARNING, 28.0);
ui.add_space(8.0);
ui.label(
RichText::new(&f.nickname)
.font(FontId::proportional(13.0))
.color(t::TEXT_PRIMARY),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if t::icon_btn_drawn(
ui,
crate::theme::icons::check,
"accept",
Some(t::SUCCESS),
18.0,
)
.clicked()
{
let net = net.clone();
let uid = f.user_id.clone();
tokio::spawn(async move {
net.send(NetCommand::FriendAccept { user_id: uid }).await;
});
}
if t::icon_btn_drawn(
ui,
crate::theme::icons::close,
"decline",
Some(t::ERROR),
18.0,
)
.clicked()
{
let net = net.clone();
let uid = f.user_id.clone();
tokio::spawn(async move {
net.send(NetCommand::FriendDecline { user_id: uid }).await;
});
}
});
});
});
}

View file

@ -1,66 +0,0 @@
use eframe::egui::{self, Context, FontId, RichText};
use crate::state::UiState;
use crate::theme as t;
use vnox_client::{identity::Identity, net::NetHandle};
mod dm;
mod friends;
mod text;
mod voice;
pub use friends::show as friends_panel;
pub fn show(
ui: &mut egui::Ui,
ctx: &Context,
s: &mut UiState,
identity: &Identity,
net: &NetHandle,
) {
// If a DM conversation is active, show the DM chat view
if let Some(dm_id) = s.active_dm_id.clone() {
// Clear any active channel when showing DM
s.active_channel = None;
dm::dm_chat(ui, ctx, s, identity, net, &dm_id);
return;
}
// Home screen — show Friends panel if no guild and no DM
if s.active_guild_id.is_none() {
friends::show(ui, s, net);
return;
}
let ch_id = match s.active_channel.clone() {
Some(id) => id,
None => {
empty_state(ui, "pick a channel");
return;
}
};
let ch = match s.channels.iter().find(|c| c.id == ch_id) {
Some(c) => c.clone(),
None => {
empty_state(ui, "channel not found");
return;
}
};
if ch.kind == "voice" {
voice::voice_panel(ui, s, &ch);
} else {
text::text_chat(ui, ctx, s, net, identity, &ch_id, &ch.name);
}
}
fn empty_state(ui: &mut egui::Ui, text: &str) {
ui.vertical_centered(|ui| {
ui.label(
RichText::new(text)
.font(FontId::monospace(12.0))
.color(t::TEXT_DEAD),
);
});
}

View file

@ -1,186 +0,0 @@
use crate::state::{ChatMessage, UiState};
use crate::theme as t;
use eframe::egui::{self, Context, FontId, Frame, RichText};
use std::collections::HashMap;
use std::time::Instant;
use vnox_client::{
identity::Identity,
net::{NetCommand, NetHandle},
};
pub(crate) fn chat_input_bar(
ui: &mut egui::Ui,
ctx: &Context,
s: &mut UiState,
net: &NetHandle,
ch_id: &str,
identity: &Identity,
) {
let r = ui.available_rect_before_wrap();
ui.painter().hline(
r.x_range(),
r.top(),
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
Frame::NONE
.fill(t::BG_SURFACE)
.inner_margin(egui::Margin::symmetric(12, 8))
.show(ui, |ui| {
// Reply / edit preview bar
if let Some((ref _reply_id, ref reply_sender)) = s.replying_to.clone() {
ui.horizontal(|ui| {
ui.label(
RichText::new("replying to")
.font(FontId::proportional(11.0))
.color(t::ACCENT),
);
let sender_nick = s.nick_for(reply_sender).to_string();
ui.label(
RichText::new(sender_nick)
.font(FontId::proportional(11.0))
.color(t::TEXT_SECONDARY),
);
if t::icon_btn_drawn(ui, crate::theme::icons::close, "cancel reply", None, 14.0)
.clicked()
{
s.replying_to = None;
}
});
}
if s.editing_message_id.is_some() {
ui.horizontal(|ui| {
ui.label(
RichText::new("editing")
.font(FontId::proportional(11.0))
.color(t::WARNING),
);
if t::icon_btn_drawn(ui, crate::theme::icons::close, "cancel edit", None, 14.0)
.clicked()
{
s.chat_input.clear();
s.editing_message_id = None;
}
});
}
ui.horizontal(|ui| {
let hint_text = if s.editing_message_id.is_some() {
"edit your message...".to_string()
} else if s.replying_to.is_some() {
"write your reply...".to_string()
} else {
format!("message in #{ch_id}")
};
let input = egui::TextEdit::singleline(&mut s.chat_input)
.font(FontId::proportional(13.0))
.text_color(t::TEXT_PRIMARY)
.hint_text(RichText::new(hint_text).color(t::TEXT_DISABLED))
.frame(false)
.desired_width(ui.available_width() - 44.0);
let enter = Frame::NONE
.fill(t::BG_INTERACTIVE)
.corner_radius(t::R_MD)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.inner_margin(egui::Margin::symmetric(12, 6))
.show(ui, |ui| ui.add(input))
.inner;
if !s.chat_input.is_empty() && s.last_typing_send.elapsed().as_secs() >= 3 {
s.last_typing_send = Instant::now();
let net = net.clone();
let c = ch_id.to_string();
tokio::spawn(async move {
net.send(NetCommand::TypingStart { channel_id: c }).await;
});
}
let submit = (enter.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|| ui
.add(
egui::Button::new(RichText::new("→").color(t::ACCENT).size(14.0))
.fill(t::ACCENT_10)
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
.corner_radius(t::R_MD)
.min_size(egui::vec2(32.0, 32.0)),
)
.clicked();
if submit && !s.chat_input.trim().is_empty() {
if let Some(edit_id) = s.editing_message_id.clone() {
let content = s.chat_input.trim().to_string();
s.chat_input.clear();
s.editing_message_id = None;
let net = net.clone();
let cid = ch_id.to_string();
tokio::spawn(async move {
net.send(NetCommand::MessageEdit {
channel_id: cid,
message_id: edit_id,
content,
})
.await;
});
} else {
let reply_to = s.replying_to.clone().map(|(id, _)| id);
send_chat(s, net, ch_id, &identity.pubkey_hex, reply_to);
s.replying_to = None;
}
ctx.request_repaint();
}
});
});
}
fn send_chat(
s: &mut UiState,
net: &NetHandle,
ch_id: &str,
sender_id: &str,
reply_to: Option<String>,
) {
let content = s.chat_input.trim().to_string();
if content.is_empty() {
return;
}
s.chat_input.clear();
s.messages
.entry(ch_id.to_string())
.or_default()
.push(ChatMessage {
message_id: uuid::Uuid::new_v4().to_string(),
sender_id: sender_id.to_string(),
content: content.clone(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64,
edited: false,
reactions: HashMap::new(),
reply_to: reply_to.clone(),
});
s.scroll_bottom = true;
let need_join = s.server_channel.as_deref() != Some(ch_id);
if need_join {
s.server_channel = Some(ch_id.to_string());
}
let net = net.clone();
let ch = ch_id.to_string();
tokio::spawn(async move {
if need_join {
net.send(NetCommand::JoinChannel {
channel_id: ch.clone(),
})
.await;
}
net.send(NetCommand::SendChat {
channel_id: ch,
content,
reply_to,
})
.await;
});
}

View file

@ -1,75 +0,0 @@
use crate::state::{TYPING_TIMEOUT, UiState};
use crate::theme as t;
use eframe::egui::{self, FontId, RichText, ScrollArea};
use std::time::Instant;
use vnox_client::{identity::Identity, net::NetHandle};
use super::row::message_row;
pub(crate) fn show(
ui: &mut egui::Ui,
s: &mut UiState,
net: &NetHandle,
identity: &Identity,
ch_id: &str,
max_h: f32,
) {
let msgs = s.messages.get(ch_id).cloned().unwrap_or_default();
ScrollArea::vertical()
.max_height(max_h)
.auto_shrink([false; 2])
.stick_to_bottom(s.scroll_bottom)
.show(ui, |ui| {
ui.add_space(12.0);
if msgs.is_empty() {
ui.label(
RichText::new("no messages yet")
.font(FontId::proportional(12.0))
.color(t::TEXT_DISABLED),
);
} else {
ui.label(
RichText::new("today")
.font(FontId::proportional(11.0))
.color(t::TEXT_DISABLED),
);
ui.add_space(8.0);
let mut prev: Option<String> = None;
for msg in &msgs {
let group_start = prev.as_deref() != Some(&msg.sender_id);
message_row(ui, s, net, identity, msg, ch_id, group_start);
prev = Some(msg.sender_id.clone());
}
}
typing_indicator(ui, s, ch_id);
ui.add_space(8.0);
});
s.scroll_bottom = false;
}
fn typing_indicator(ui: &mut egui::Ui, s: &mut UiState, ch_id: &str) {
let now = Instant::now();
if let Some(typers) = s.typing_users.get_mut(ch_id) {
typers.retain(|(_, t)| now.duration_since(*t) < TYPING_TIMEOUT);
if typers.is_empty() {
s.typing_users.remove(ch_id);
return;
}
let names: Vec<&str> = typers.iter().map(|(n, _)| n.as_str()).collect();
let label = if names.len() == 1 {
format!("{} is typing...", names[0])
} else {
format!(
"{} and {} are typing...",
names[..names.len() - 1].join(", "),
names[names.len() - 1]
)
};
ui.add_space(4.0);
ui.label(
RichText::new(label)
.font(FontId::proportional(11.0))
.color(t::TEXT_DISABLED),
);
}
}

View file

@ -1,130 +0,0 @@
pub mod list;
pub mod row;
pub(crate) use list::show;
pub use row::message_row;
use crate::state::{ChatMessage, UiState};
use crate::theme as t;
use crate::theme::icons;
use eframe::egui::{self, RichText};
use vnox_client::{
identity::Identity,
net::{NetCommand, NetHandle},
};
fn own_actions(
ui: &mut egui::Ui,
s: &mut UiState,
net: &NetHandle,
msg: &ChatMessage,
ch_id: &str,
) {
if t::icon_btn_drawn(ui, icons::pencil, "edit", None, 16.0).clicked() {
s.chat_input = msg.content.clone();
s.editing_message_id = Some(msg.message_id.clone());
}
if t::icon_btn_drawn(ui, icons::trash, "delete", None, 16.0).clicked() {
let net = net.clone();
let mid = msg.message_id.clone();
let cid = ch_id.to_string();
tokio::spawn(async move {
net.send(NetCommand::MessageDelete {
channel_id: cid,
message_id: mid,
})
.await;
});
}
}
fn reaction_buttons(
ui: &mut egui::Ui,
net: &NetHandle,
msg: &ChatMessage,
ch_id: &str,
identity: &Identity,
) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for (emoji, users) in &msg.reactions {
let count = users.len();
let has_reacted = users.iter().any(|u| u == &identity.pubkey_hex);
let bg = if has_reacted {
t::ACCENT_20
} else {
t::BG_ELEVATED
};
let border = if has_reacted {
t::BORDER_ACCENT
} else {
t::BORDER_SUBTLE
};
let label = format!("{} {}", emoji, count);
if ui
.add(
egui::Button::new(RichText::new(&label).size(12.0).color(t::TEXT_SECONDARY))
.fill(bg)
.stroke(egui::Stroke::new(1.0, border))
.corner_radius(4.0)
.min_size(egui::Vec2::ZERO),
)
.clicked()
{
let net = net.clone();
let mid = msg.message_id.clone();
let cid = ch_id.to_string();
let e = emoji.clone();
if has_reacted {
tokio::spawn(async move {
net.send(NetCommand::ReactionRemove {
channel_id: cid,
message_id: mid,
emoji: e,
})
.await;
});
} else {
tokio::spawn(async move {
net.send(NetCommand::ReactionAdd {
channel_id: cid,
message_id: mid,
emoji: e,
})
.await;
});
}
}
}
});
}
fn read_receipt_indicator(ui: &mut egui::Ui, s: &UiState) {
if let Some(ch) = &s.active_channel
&& s.read_receipts.contains_key(ch)
{
ui.add_space(6.0);
let (rr, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover());
icons::check(ui.painter(), rr, t::TEXT_DISABLED);
}
}
fn fmt_ts(ts: i64) -> String {
let s = (ts / 1000).abs() % 86400;
format!("{:02}:{:02}", s / 3600, (s % 3600) / 60)
}
fn nick_color(s: &UiState, sender_id: &str) -> egui::Color32 {
if let Some(color) = s.user_colors.get(sender_id) {
return *color;
}
let nick = s.nick_for(sender_id);
let c = [
t::ACCENT,
t::SUCCESS,
t::WARNING,
t::INFO,
egui::Color32::from_rgb(200, 100, 180),
];
c[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % c.len()]
}

View file

@ -1,220 +0,0 @@
use crate::state::{ChatMessage, UiState};
use crate::theme as t;
use eframe::egui::{self, FontId, Frame, RichText};
use vnox_client::{
identity::Identity,
net::{NetCommand, NetHandle},
};
use super::{fmt_ts, nick_color, own_actions, reaction_buttons, read_receipt_indicator};
pub fn message_row(
ui: &mut egui::Ui,
s: &mut UiState,
net: &NetHandle,
identity: &Identity,
msg: &ChatMessage,
ch_id: &str,
group_start: bool,
) {
let nick = s.nick_for(&msg.sender_id).to_string();
let is_own = msg.sender_id == identity.pubkey_hex;
let msg_id = msg.message_id.clone();
let sender_id = msg.sender_id.clone();
let content_clone = msg.content.clone();
let row_response = Frame::NONE
.inner_margin(egui::Margin {
left: 16,
right: 16,
top: if group_start { 6 } else { 1 },
bottom: 0,
})
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
if group_start {
let init = nick
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string();
t::avatar(ui, &init, nick_color(s, &msg.sender_id), 32.0);
} else {
ui.add_space(32.0);
}
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
if group_start {
ui.horizontal(|ui| {
ui.label(
RichText::new(&nick)
.font(FontId::proportional(13.0))
.color(t::TEXT_PRIMARY)
.strong(),
);
ui.label(
RichText::new(fmt_ts(msg.timestamp))
.font(FontId::proportional(11.0))
.color(t::TEXT_DISABLED),
);
if msg.edited {
ui.label(
RichText::new("(edited)")
.font(FontId::proportional(10.0))
.color(t::TEXT_DISABLED),
);
}
if is_own {
own_actions(ui, s, net, msg, ch_id);
}
});
}
// Reply indicator
if let Some(reply_id) = &msg.reply_to {
let reply_snippet = s
.messages
.get(ch_id)
.and_then(|msgs| msgs.iter().find(|m| &m.message_id == reply_id))
.map(|m| {
let reply_nick = s.nick_for(&m.sender_id).to_string();
let snippet: String = m.content.chars().take(60).collect();
format!("→ {reply_nick}: {snippet}")
})
.unwrap_or_else(|| "→ (deleted message)".to_string());
ui.label(
RichText::new(reply_snippet)
.font(FontId::proportional(11.0))
.color(t::TEXT_MUTED)
.italics(),
);
}
ui.horizontal(|ui| {
ui.label(
RichText::new(&msg.content)
.font(FontId::proportional(13.0))
.color(t::TEXT_SECONDARY),
);
read_receipt_indicator(ui, s);
});
if !msg.reactions.is_empty() {
reaction_buttons(ui, net, msg, ch_id, identity);
}
});
});
})
.response;
// Right-click context menu (egui 0.33 API: Response::context_menu).
row_response.context_menu(|ui| {
s.context_menu_message_id = Some(msg_id.clone());
let quick_reactions = ["👍", "❤️", "😂", "🎉", "👀", "🤔"];
ui.horizontal(|ui| {
for emoji in quick_reactions {
let has_reacted = msg
.reactions
.get(emoji)
.is_some_and(|users| users.contains(&identity.pubkey_hex));
let btn_text = if has_reacted {
format!("{emoji} +")
} else {
emoji.to_string()
};
if ui
.button(RichText::new(btn_text).font(FontId::proportional(14.0)))
.clicked()
{
let net = net.clone();
let mid = msg_id.clone();
let cid = ch_id.to_string();
let e = emoji.to_string();
if has_reacted {
tokio::spawn(async move {
net.send(NetCommand::ReactionRemove {
channel_id: cid,
message_id: mid,
emoji: e,
})
.await;
});
} else {
tokio::spawn(async move {
net.send(NetCommand::ReactionAdd {
channel_id: cid,
message_id: mid,
emoji: e,
})
.await;
});
}
ui.close();
}
}
});
ui.separator();
if ui
.add(
egui::Button::new(RichText::new("Reply").font(FontId::proportional(12.0)))
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.replying_to = Some((msg_id.clone(), sender_id.clone()));
s.editing_message_id = None;
ui.close();
}
if ui
.add(
egui::Button::new(RichText::new("Copy text").font(FontId::proportional(12.0)))
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
ui.ctx().copy_text(content_clone.clone());
ui.close();
}
if is_own {
ui.separator();
if ui
.add(
egui::Button::new(RichText::new("Edit").font(FontId::proportional(12.0)))
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.chat_input = content_clone.clone();
s.editing_message_id = Some(msg_id.clone());
s.replying_to = None;
ui.close();
}
if ui
.add(
egui::Button::new(
RichText::new("Delete")
.font(FontId::proportional(12.0))
.color(t::ERROR),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
let net = net.clone();
let mid = msg_id.clone();
let cid = ch_id.to_string();
tokio::spawn(async move {
net.send(NetCommand::MessageDelete {
channel_id: cid,
message_id: mid,
})
.await;
});
ui.close();
}
}
});
}

View file

@ -1,77 +0,0 @@
mod input;
mod messages;
use eframe::egui::{self, Context, FontId, Frame, RichText};
use vnox_client::{identity::Identity, net::NetHandle};
use crate::state::UiState;
use crate::theme as t;
pub(super) use messages::message_row;
pub(crate) fn text_chat(
ui: &mut egui::Ui,
ctx: &Context,
s: &mut UiState,
net: &NetHandle,
identity: &Identity,
ch_id: &str,
ch_name: &str,
) {
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
Frame::NONE
.fill(t::BG_SURFACE)
.inner_margin(egui::Margin::symmetric(16, 0))
.show(ui, |ui| {
ui.set_height(40.0);
ui.horizontal_centered(|ui| {
ui.label(RichText::new("#").color(t::TEXT_MUTED).size(16.0));
ui.add_space(4.0);
ui.label(
RichText::new(ch_name)
.font(FontId::proportional(14.0))
.color(t::TEXT_PRIMARY)
.strong(),
);
});
});
let r = ui.max_rect();
ui.painter().hline(
r.x_range(),
r.top() + 40.0,
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
if let Some(elapsed) = s.voice_elapsed() {
voice_banner(ui, s, &elapsed);
}
let input_h = 52.0;
messages::show(ui, s, net, identity, ch_id, ui.available_height() - input_h);
input::chat_input_bar(ui, ctx, s, net, ch_id, identity);
});
}
fn voice_banner(ui: &mut egui::Ui, s: &UiState, elapsed: &str) {
Frame::NONE
.fill(t::BG_ELEVATED)
.inner_margin(egui::Margin::symmetric(16, 5))
.show(ui, |ui| {
ui.horizontal(|ui| {
let (dot, _) = ui.allocate_exact_size(egui::vec2(7.0, 7.0), egui::Sense::hover());
ui.painter().circle_filled(dot.center(), 3.5, t::SUCCESS);
ui.label(RichText::new("voice").color(t::SUCCESS).size(12.0));
ui.label(RichText::new("·").color(t::TEXT_DISABLED).size(12.0));
if let Some(ch) = s.active_voice_channel() {
ui.label(RichText::new(&ch.name).color(t::TEXT_SECONDARY).size(12.0));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(elapsed)
.font(FontId::monospace(11.0))
.color(t::TEXT_DISABLED),
);
});
});
});
}

View file

@ -1,147 +0,0 @@
mod panel;
use eframe::egui::{self, FontId, Frame, RichText};
use crate::state::{Channel, UiState, VoiceUiState};
use crate::theme as t;
pub(crate) fn voice_panel(ui: &mut egui::Ui, s: &mut UiState, ch: &Channel) {
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
Frame::NONE
.fill(t::BG_SURFACE)
.inner_margin(egui::Margin::symmetric(16, 0))
.show(ui, |ui| {
ui.set_height(40.0);
ui.horizontal_centered(|ui| {
ui.label(RichText::new("~").color(t::TEXT_MUTED).size(15.0));
ui.add_space(4.0);
ui.label(
RichText::new(&ch.name)
.font(FontId::proportional(14.0))
.color(t::TEXT_PRIMARY)
.strong(),
);
if let Some(elapsed) = s.voice_elapsed() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(elapsed)
.font(FontId::monospace(11.0))
.color(t::TEXT_DISABLED),
);
});
}
});
});
let r = ui.max_rect();
ui.painter().hline(
r.x_range(),
r.top() + 40.0,
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
ui.add_space(16.0);
Frame::NONE
.inner_margin(egui::Margin::symmetric(20, 0))
.show(ui, |ui| {
// Voice activity banner — shows when local or remote user is speaking
if s.local_speaking || s.remote_speaking {
voice_activity_banner(ui, s);
ui.add_space(8.0);
}
if ch.members.is_empty() {
ui.label(
RichText::new("no members yet")
.font(FontId::proportional(12.0))
.color(t::TEXT_DISABLED),
);
}
// Identify which member is currently speaking by sender_id.
let speaking_uid = if !s.last_remote_speaker_id.is_empty() {
Some(s.last_remote_speaker_id.clone())
} else {
None
};
for nick in &ch.members {
let user_id = s
.user_names
.iter()
.find(|(_, n)| *n == nick)
.map(|(id, _)| id.clone())
.unwrap_or_else(|| nick.clone());
let member_speaking = speaking_uid.as_deref() == Some(&user_id);
panel::member_card(
ui,
nick,
&user_id,
&mut s.per_user_volumes,
member_speaking,
);
ui.add_space(6.0);
}
});
if let VoiceUiState::Error(ref err) = s.voice {
ui.add_space(16.0);
Frame::NONE
.fill(egui::Color32::from_rgba_premultiplied(217, 96, 74, 20))
.corner_radius(t::R_MD)
.stroke(egui::Stroke::new(
1.0,
egui::Color32::from_rgba_premultiplied(217, 96, 74, 60),
))
.inner_margin(egui::Margin::same(12))
.show(ui, |ui| {
ui.label(
RichText::new(format!("microphone error: {err}"))
.font(FontId::proportional(12.0))
.color(t::ERROR),
);
});
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| {
ui.add_space(16.0);
panel::voice_controls(ui, s);
});
});
}
fn voice_activity_banner(ui: &mut egui::Ui, s: &UiState) {
let remote_nick: Option<String> = if !s.last_remote_speaker_id.is_empty() {
Some(s.nick_for(&s.last_remote_speaker_id).to_string())
} else {
None
};
let (label, color) = match (s.local_speaking, s.remote_speaking, remote_nick.as_deref()) {
(true, true, Some(nick)) => (format!("you and {nick} are talking"), t::SUCCESS),
(true, true, None) => ("you and others are talking".to_string(), t::SUCCESS),
(true, false, _) => ("you are talking".to_string(), t::SUCCESS),
(false, true, Some(nick)) => (format!("{nick} is talking"), t::INFO),
(false, true, None) => ("someone is talking".to_string(), t::INFO),
_ => return,
};
Frame::NONE
.fill(egui::Color32::from_rgba_premultiplied(
color.r(),
color.g(),
color.b(),
20,
))
.corner_radius(t::R_MD)
.stroke(egui::Stroke::new(1.0, color))
.inner_margin(egui::Margin::symmetric(12, 6))
.show(ui, |ui| {
ui.horizontal(|ui| {
// Pulsating dot
let (r, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 5.0, color);
ui.label(
RichText::new(label)
.font(FontId::proportional(12.0))
.color(color)
.strong(),
);
});
});
}

View file

@ -1,199 +0,0 @@
use eframe::egui::{self, FontId, Frame, Rect, RichText};
use crate::state::UiState;
use crate::theme as t;
use crate::theme::icons;
pub(super) fn member_card(
ui: &mut egui::Ui,
nick: &str,
user_id: &str,
volumes: &mut std::collections::HashMap<String, f32>,
speaking: bool,
) {
let vol = volumes.entry(user_id.to_string()).or_insert(80.0);
let avatar_color = if speaking {
t::SUCCESS
} else {
nick_color(nick)
};
Frame::NONE
.fill(if speaking {
egui::Color32::from_rgba_premultiplied(
t::SUCCESS.r(),
t::SUCCESS.g(),
t::SUCCESS.b(),
15,
)
} else {
t::BG_ELEVATED
})
.corner_radius(t::R_MD)
.stroke(if speaking {
egui::Stroke::new(1.0, t::SUCCESS)
} else {
egui::Stroke::new(1.0, t::BORDER_DEFAULT)
})
.inner_margin(egui::Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.horizontal(|ui| {
let init = nick
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string();
t::avatar(ui, &init, avatar_color, 28.0);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(nick)
.font(FontId::proportional(13.0))
.color(if speaking {
t::SUCCESS
} else {
t::TEXT_PRIMARY
})
.strong(),
);
if speaking {
let (sr, _) = ui
.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
icons::speaker(ui.painter(), sr, t::SUCCESS);
}
});
ui.horizontal(|ui| {
ui.label(
RichText::new("vol")
.font(FontId::proportional(9.0))
.color(t::TEXT_DISABLED),
);
ui.add(
egui::Slider::new(vol, 0.0..=100.0)
.show_value(false)
.smallest_positive(0.0),
);
});
});
});
});
}
pub(super) fn voice_controls(ui: &mut egui::Ui, s: &mut UiState) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
// Microphone button — drawn icon.
let mic_color = if s.mic_enabled {
t::TEXT_SECONDARY
} else {
t::ERROR
};
let mic_fill = if s.mic_enabled {
t::BG_ELEVATED
} else {
egui::Color32::from_rgba_premultiplied(217, 96, 74, 20)
};
let mic_stroke = if s.mic_enabled {
egui::Stroke::new(1.0, t::BORDER_DEFAULT)
} else {
egui::Stroke::new(1.0, egui::Color32::from_rgba_premultiplied(217, 96, 74, 80))
};
let (mic_rect, mic_resp) =
ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::click());
if ui.is_rect_visible(mic_rect) {
let painter = ui.painter();
painter.rect_filled(mic_rect, t::R_FULL, mic_fill);
painter.rect_stroke(mic_rect, t::R_FULL, mic_stroke, egui::StrokeKind::Middle);
let mic_icon = if s.mic_enabled {
icons::microphone
} else {
icons::microphone_off
};
let inner = inset(mic_rect, 10.0);
mic_icon(painter, inner, mic_color);
}
if mic_resp.clicked() {
s.mic_enabled = !s.mic_enabled;
}
mic_resp.on_hover_text("microphone");
// Deafen button — drawn icon.
let deaf_color = if s.deafen {
t::ERROR
} else {
t::TEXT_SECONDARY
};
let deaf_fill = if s.deafen {
egui::Color32::from_rgba_premultiplied(217, 96, 74, 20)
} else {
t::BG_ELEVATED
};
let (deaf_rect, deaf_resp) =
ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::click());
if ui.is_rect_visible(deaf_rect) {
let painter = ui.painter();
painter.rect_filled(deaf_rect, t::R_FULL, deaf_fill);
painter.rect_stroke(
deaf_rect,
t::R_FULL,
egui::Stroke::new(1.0, t::BORDER_DEFAULT),
egui::StrokeKind::Middle,
);
let deaf_icon = if s.deafen {
icons::headphones_off
} else {
icons::headphones
};
let inner = inset(deaf_rect, 10.0);
deaf_icon(painter, inner, deaf_color);
}
if deaf_resp.clicked() {
s.deafen = !s.deafen;
}
deaf_resp.on_hover_text("deafen");
// Leave button — drawn X on red background.
let (leave_rect, leave_resp) =
ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::click());
if ui.is_rect_visible(leave_rect) {
let painter = ui.painter();
painter.rect_filled(leave_rect, t::R_FULL, t::ERROR);
let inner = inset(leave_rect, 12.0);
icons::close(painter, inner, egui::Color32::from_rgb(26, 16, 8));
}
if leave_resp.clicked()
&& let Some(id) = s.active_channel.clone()
{
if s.channels.iter().any(|c| c.id == id && c.kind == "text") {
s.active_channel = Some("general".into());
} else {
s.active_channel = s
.channels
.iter()
.find(|c| c.kind == "text")
.map(|c| c.id.clone());
}
}
leave_resp.on_hover_text("leave voice channel");
});
}
fn inset(rect: Rect, pad: f32) -> Rect {
Rect::from_min_max(
(rect.min.x + pad, rect.min.y + pad).into(),
(rect.max.x - pad, rect.max.y - pad).into(),
)
}
fn nick_color(nick: &str) -> egui::Color32 {
let colors = [
t::ACCENT,
t::SUCCESS,
t::WARNING,
t::INFO,
egui::Color32::from_rgb(200, 100, 180),
];
colors[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % colors.len()]
}

View file

@ -1,45 +0,0 @@
use eframe::egui::Color32;
use crate::state::{ConnState, UiState};
use crate::theme as t;
use vnox_client::net::{NetCommand, NetHandle};
pub fn disconnect(s: &mut UiState, net: &NetHandle) {
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::Disconnect).await;
});
s.conn = ConnState::Disconnected;
}
pub fn connect_to(s: &mut UiState, net: &NetHandle, addr: &str) {
if addr.is_empty() {
return;
}
if let Some(i) = s.bookmarks.iter().position(|b| b.address == addr) {
s.selected_bookmark = i;
}
s.connect_input = addr.to_string();
s.conn = ConnState::Connecting;
let net = net.clone();
let address = addr.to_string();
let auto_reconnect = s.auto_reconnect;
tokio::spawn(async move {
net.send(NetCommand::Connect {
address,
auto_reconnect,
})
.await;
});
}
pub(crate) fn msg_color(msg: &str) -> Color32 {
let l = msg.to_ascii_lowercase();
if l.contains("error") || l.contains("fail") || l.contains("refuse") {
t::DANGER
} else if l.contains("connected") {
t::SUCCESS
} else {
t::TEXT_MUTED
}
}

View file

@ -1,130 +0,0 @@
mod add;
use eframe::egui::{self, Frame};
use vnox_client::net::NetHandle;
use crate::state::{ConnState, UiState};
use crate::theme as t;
use super::action::connect_to;
pub fn server_list(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
Frame::NONE
.fill(t::BG_ELEVATED)
.corner_radius(t::R_LG)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.inner_margin(egui::Margin::same(16))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("SERVERS")
.font(egui::FontId::proportional(11.0))
.color(t::TEXT_MUTED),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let add_btn = egui::Button::new(
egui::RichText::new("+")
.font(egui::FontId::proportional(16.0))
.color(t::ACCENT_SOFT),
)
.fill(t::ACCENT_10)
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
.corner_radius(t::R_SM)
.min_size(egui::vec2(28.0, 28.0));
if ui.add(add_btn).on_hover_text("add server").clicked() {
s.add_server_open = !s.add_server_open;
if s.add_server_open {
s.add_server_input = String::new();
}
}
});
});
if s.add_server_open {
add::add_form(ui, s);
}
ui.add_space(4.0);
let busy = matches!(
s.conn,
ConnState::Connecting | ConnState::Reconnecting { .. }
);
if s.bookmarks.is_empty() {
ui.add_space(12.0);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new("no servers yet — click + to add one")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_DISABLED),
);
});
ui.add_space(12.0);
} else {
for (i, bm) in s.bookmarks.clone().into_iter().enumerate() {
let active = s.connect_input == bm.address;
let bg = if active {
t::ACCENT_10
} else {
egui::Color32::TRANSPARENT
};
let stroke = if active {
egui::Stroke::new(1.0, t::BORDER_ACCENT)
} else {
egui::Stroke::NONE
};
let resp = Frame::NONE
.fill(bg)
.stroke(stroke)
.corner_radius(t::R_SM)
.inner_margin(egui::Margin::symmetric(8, 4))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("🖥").font(egui::FontId::proportional(13.0)),
);
ui.add_space(6.0);
ui.vertical(|ui| {
ui.label(
egui::RichText::new(&bm.label)
.font(egui::FontId::proportional(13.0))
.color(t::TEXT_PRIMARY),
);
ui.label(
egui::RichText::new(&bm.address)
.font(egui::FontId::monospace(10.0))
.color(t::TEXT_MUTED),
);
});
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if t::icon_btn_drawn(
ui,
crate::theme::icons::close,
"remove bookmark",
None,
16.0,
)
.clicked()
{
s.remove_bookmark(i);
}
},
);
});
})
.response
.interact(egui::Sense::click());
if resp.clicked() && !busy {
s.connect_input = bm.address.clone();
s.selected_bookmark = i;
if matches!(s.conn, ConnState::Disconnected) {
connect_to(s, net, &bm.address);
}
}
}
}
});
}

View file

@ -1,51 +0,0 @@
use crate::state::UiState;
use crate::theme as t;
use eframe::egui::{self, Frame};
pub fn add_form(ui: &mut egui::Ui, s: &mut UiState) {
ui.add_space(8.0);
let mut addr = s.add_server_input.clone();
Frame::NONE
.fill(t::BG_INTERACTIVE)
.corner_radius(t::R_SM)
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
.inner_margin(egui::Margin::symmetric(10, 6))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut addr)
.font(egui::FontId::monospace(12.0))
.text_color(t::TEXT_PRIMARY)
.hint_text(
egui::RichText::new("ip:port or host:port").color(t::TEXT_DISABLED),
)
.desired_width(ui.available_width() - 120.0)
.frame(false),
);
let save_btn = egui::Button::new(
egui::RichText::new("Save")
.font(egui::FontId::proportional(11.0))
.color(t::TEXT_PRIMARY)
.strong(),
)
.fill(t::BG_INTERACTIVE)
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
.corner_radius(t::R_SM)
.min_size(egui::vec2(50.0, 24.0));
if ui.add(save_btn).clicked() && !addr.trim().is_empty() {
s.connect_input = addr.trim().to_string();
s.add_bookmark_from_input();
s.add_server_open = false;
s.add_server_input = String::new();
s.connect_input = String::new();
}
if t::icon_btn_drawn(ui, crate::theme::icons::close, "cancel", None, 18.0).clicked()
{
s.add_server_open = false;
s.add_server_input = String::new();
}
});
});
s.add_server_input = addr;
ui.add_space(6.0);
}

View file

@ -1,149 +0,0 @@
use eframe::egui::{self, Frame};
use vnox_client::{identity::Identity, net::NetHandle};
use crate::state::{ConnState, UiState};
use crate::theme as t;
mod action;
mod bookmarks;
pub use action::{connect_to, disconnect};
pub fn show(ui: &mut egui::Ui, s: &mut UiState, _identity: &Identity, net: &NetHandle) {
ui.vertical_centered(|ui| {
ui.set_max_width(520.0);
ui.add_space(40.0);
ui.label(
egui::RichText::new("VNOX")
.font(egui::FontId::proportional(32.0))
.color(t::TEXT_PRIMARY)
.strong(),
);
ui.add_space(2.0);
ui.label(
egui::RichText::new("self-hosted voice & chat")
.font(egui::FontId::proportional(13.0))
.color(t::TEXT_MUTED),
);
ui.add_space(32.0);
bookmarks::server_list(ui, s, net);
ui.add_space(20.0);
ui.label(
egui::RichText::new("Quick Connect")
.font(egui::FontId::proportional(11.0))
.color(t::TEXT_MUTED),
);
ui.add_space(6.0);
Frame::NONE
.fill(t::BG_INTERACTIVE)
.corner_radius(t::R_MD)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.inner_margin(egui::Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(">")
.font(egui::FontId::monospace(12.0))
.color(t::ACCENT_SOFT),
);
let w = ui.available_width().max(80.0);
ui.add(
egui::TextEdit::singleline(&mut s.connect_input)
.font(egui::FontId::monospace(13.0))
.text_color(t::TEXT_PRIMARY)
.frame(false)
.hint_text(
egui::RichText::new("127.0.0.1:7600").color(t::TEXT_DISABLED),
)
.desired_width(w),
);
});
});
let enter_pressed = ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.add_space(8.0);
let label = match &s.conn {
ConnState::Disconnected => "Connect",
ConnState::Connecting | ConnState::Reconnecting { .. } => "Cancel",
ConnState::Connected { .. } => "Disconnect",
};
let btn = egui::Button::new(
egui::RichText::new(label)
.font(egui::FontId::proportional(13.0))
.color(t::TEXT_PRIMARY)
.strong(),
)
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
.corner_radius(t::R_MD)
.min_size(egui::vec2(200.0, 38.0));
if ui.add(btn).clicked() || enter_pressed {
match &s.conn {
ConnState::Disconnected => {
let addr = s.connect_input.trim().to_owned();
if !addr.is_empty() {
connect_to(s, net, &addr);
}
}
_ => disconnect(s, net),
}
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.checkbox(&mut s.auto_reconnect, "");
ui.label(
egui::RichText::new("auto-reconnect on drop")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_MUTED),
);
});
if let Some(ref msg) = s.status_msg {
ui.add_space(8.0);
ui.label(
egui::RichText::new(msg)
.font(egui::FontId::proportional(11.0))
.color(action::msg_color(msg)),
);
}
ui.add_space(28.0);
let sep = ui.available_rect_before_wrap();
let painter = ui.painter();
painter.hline(
sep.x_range(),
sep.top(),
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("💡").font(egui::FontId::proportional(13.0)));
ui.label(
egui::RichText::new("Host your own —")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_MUTED),
);
ui.label(
egui::RichText::new("github.com/loki5512344/Vnox")
.font(egui::FontId::proportional(12.0))
.color(t::ACCENT_SOFT),
);
ui.label(
egui::RichText::new(" · self-host in 5 min")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_DISABLED),
);
});
ui.add_space(20.0);
});
}

View file

@ -1,133 +0,0 @@
use eframe::egui::Key;
pub(crate) fn name_from_key(key: Key) -> Option<&'static str> {
match key {
Key::Space => Some("space"),
Key::A => Some("a"),
Key::B => Some("b"),
Key::C => Some("c"),
Key::D => Some("d"),
Key::E => Some("e"),
Key::F => Some("f"),
Key::G => Some("g"),
Key::H => Some("h"),
Key::I => Some("i"),
Key::J => Some("j"),
Key::K => Some("k"),
Key::L => Some("l"),
Key::M => Some("m"),
Key::N => Some("n"),
Key::O => Some("o"),
Key::P => Some("p"),
Key::Q => Some("q"),
Key::R => Some("r"),
Key::S => Some("s"),
Key::T => Some("t"),
Key::U => Some("u"),
Key::V => Some("v"),
Key::W => Some("w"),
Key::X => Some("x"),
Key::Y => Some("y"),
Key::Z => Some("z"),
Key::F1 => Some("f1"),
Key::F2 => Some("f2"),
Key::F3 => Some("f3"),
Key::F4 => Some("f4"),
Key::F5 => Some("f5"),
Key::F6 => Some("f6"),
Key::F7 => Some("f7"),
Key::F8 => Some("f8"),
Key::F9 => Some("f9"),
Key::F10 => Some("f10"),
Key::F11 => Some("f11"),
Key::F12 => Some("f12"),
_ => None,
}
}
pub(crate) fn key_from_name(name: &str) -> Option<Key> {
match name {
"a" => Some(Key::A),
"b" => Some(Key::B),
"c" => Some(Key::C),
"d" => Some(Key::D),
"e" => Some(Key::E),
"f" => Some(Key::F),
"g" => Some(Key::G),
"h" => Some(Key::H),
"i" => Some(Key::I),
"j" => Some(Key::J),
"k" => Some(Key::K),
"l" => Some(Key::L),
"m" => Some(Key::M),
"n" => Some(Key::N),
"o" => Some(Key::O),
"p" => Some(Key::P),
"q" => Some(Key::Q),
"r" => Some(Key::R),
"s" => Some(Key::S),
"t" => Some(Key::T),
"u" => Some(Key::U),
"v" => Some(Key::V),
"w" => Some(Key::W),
"x" => Some(Key::X),
"y" => Some(Key::Y),
"z" => Some(Key::Z),
"space" => Some(Key::Space),
"f1" => Some(Key::F1),
"f2" => Some(Key::F2),
"f3" => Some(Key::F3),
"f4" => Some(Key::F4),
"f5" => Some(Key::F5),
"f6" => Some(Key::F6),
"f7" => Some(Key::F7),
"f8" => Some(Key::F8),
"f9" => Some(Key::F9),
"f10" => Some(Key::F10),
"f11" => Some(Key::F11),
"f12" => Some(Key::F12),
_ => None,
}
}
pub(crate) const ALL_KEYS: &[Key] = &[
Key::A,
Key::B,
Key::C,
Key::D,
Key::E,
Key::F,
Key::G,
Key::H,
Key::I,
Key::J,
Key::K,
Key::L,
Key::M,
Key::N,
Key::O,
Key::P,
Key::Q,
Key::R,
Key::S,
Key::T,
Key::U,
Key::V,
Key::W,
Key::X,
Key::Y,
Key::Z,
Key::Space,
Key::F1,
Key::F2,
Key::F3,
Key::F4,
Key::F5,
Key::F6,
Key::F7,
Key::F8,
Key::F9,
Key::F10,
Key::F11,
Key::F12,
];

View file

@ -1,114 +0,0 @@
//! Global hotkey detection (egui input snapshot).
mod bindings;
use eframe::egui::{self, Key, PointerButton};
use bindings::{ALL_KEYS, key_from_name, name_from_key};
/// True while the binding is held (push-to-talk).
pub fn binding_held(ctx: &egui::Context, binding: &str) -> bool {
let b = binding.trim().to_lowercase();
ctx.input(|i| match b.as_str() {
"mouse4" | "mb4" | "x1" => i.pointer.button_down(PointerButton::Extra1),
"mouse5" | "mb5" | "x2" => i.pointer.button_down(PointerButton::Extra2),
"lmb" | "mouse1" => i.pointer.button_down(PointerButton::Primary),
"rmb" | "mouse2" => i.pointer.button_down(PointerButton::Secondary),
"mmb" | "mouse3" => i.pointer.button_down(PointerButton::Middle),
"space" => i.key_down(Key::Space),
_ => combo_down(&b, i),
})
}
/// Rising edge (toggle mute/deafen, etc.).
pub fn binding_pressed(ctx: &egui::Context, binding: &str) -> bool {
let b = binding.trim().to_lowercase();
ctx.input(|i| match b.as_str() {
"mouse4" | "mb4" | "x1" => i.pointer.button_pressed(PointerButton::Extra1),
"mouse5" | "mb5" | "x2" => i.pointer.button_pressed(PointerButton::Extra2),
"ctrl+m" => i.modifiers.ctrl && i.key_pressed(Key::M),
"ctrl+d" => i.modifiers.ctrl && i.key_pressed(Key::D),
"ctrl+shift+o" => i.modifiers.ctrl && i.modifiers.shift && i.key_pressed(Key::O),
_ => combo_pressed(&b, i),
})
}
/// Listen for any key/mouse press and return a binding string like "ctrl+m" or "mouse4".
pub fn listen_pressed(ctx: &egui::Context) -> Option<String> {
ctx.input(|i| {
for &key in ALL_KEYS {
if i.key_pressed(key) {
let mut parts: Vec<&str> = Vec::new();
if i.modifiers.ctrl {
parts.push("ctrl");
}
if i.modifiers.shift {
parts.push("shift");
}
if i.modifiers.alt {
parts.push("alt");
}
if let Some(name) = name_from_key(key) {
parts.push(name);
return Some(parts.join("+"));
}
}
}
if i.pointer.button_pressed(PointerButton::Extra1) {
return Some("mouse4".into());
}
if i.pointer.button_pressed(PointerButton::Extra2) {
return Some("mouse5".into());
}
if i.pointer.button_pressed(PointerButton::Middle) {
return Some("mouse3".into());
}
None
})
}
fn combo_down(binding: &str, i: &egui::InputState) -> bool {
let Some((mods, key)) = parse_combo(binding) else {
return false;
};
mods.ctrl == i.modifiers.ctrl
&& mods.shift == i.modifiers.shift
&& mods.alt == i.modifiers.alt
&& i.key_down(key)
}
fn combo_pressed(binding: &str, i: &egui::InputState) -> bool {
let Some((mods, key)) = parse_combo(binding) else {
return false;
};
mods.ctrl == i.modifiers.ctrl
&& mods.shift == i.modifiers.shift
&& mods.alt == i.modifiers.alt
&& i.key_pressed(key)
}
#[derive(Default)]
struct Mods {
ctrl: bool,
shift: bool,
alt: bool,
}
fn parse_combo(binding: &str) -> Option<(Mods, Key)> {
let parts: Vec<&str> = binding.split('+').map(str::trim).collect();
if parts.is_empty() {
return None;
}
let mut mods = Mods::default();
let key_name = *parts.last()?;
for p in &parts[..parts.len().saturating_sub(1)] {
match *p {
"ctrl" | "control" => mods.ctrl = true,
"shift" => mods.shift = true,
"alt" => mods.alt = true,
_ => {}
}
}
let key = key_from_name(key_name)?;
Some((mods, key))
}

View file

@ -1,161 +0,0 @@
//! Right-hand member list panel — shows online/offline members
//! of the currently active channel.
use eframe::egui::{
self, Color32, Context, FontId, Frame, RichText, ScrollArea, SidePanel, Stroke,
};
use crate::state::{Channel, UiState};
use crate::theme as t;
const PANEL_WIDTH: f32 = 220.0;
pub fn show(ctx: &Context, s: &mut UiState) {
// Only show for guild text channels (not DMs, not voice-only view, not home/friends).
let ch_id = match &s.active_channel {
Some(id) => id.clone(),
None => return,
};
if s.active_guild_id.is_none() {
return;
}
if s.active_dm_id.is_some() {
return;
}
let channel = match s.channels.iter().find(|c| c.id == ch_id) {
Some(c) => c.clone(),
None => return,
};
if channel.kind == "voice" {
return;
}
SidePanel::right("members")
.exact_width(PANEL_WIDTH)
.resizable(false)
.frame(Frame::NONE.fill(t::BG_SURFACE))
.show_separator_line(false)
.show(ctx, |ui| {
let r = ui.max_rect();
ui.painter()
.vline(r.left(), r.y_range(), Stroke::new(1.0, t::BORDER_SUBTLE));
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
ui.add_space(12.0);
render_section(ui, s, &channel, true);
ui.add_space(8.0);
render_section(ui, s, &channel, false);
});
});
}
fn render_section(ui: &mut egui::Ui, s: &UiState, channel: &Channel, online: bool) {
// Members currently in this channel
let mut members: Vec<&str> = channel.members.iter().map(|m| s.nick_for(m)).collect();
// Add current user if not present
let self_nick = "you";
if !members.contains(&self_nick) {
members.push(self_nick);
}
// Sort alphabetically (case-insensitive)
members.sort_by_key(|a| a.to_lowercase());
let visible: Vec<&str> = if online {
members.clone()
} else {
Vec::new() // we don't track offline members per channel yet
};
if online && visible.is_empty() {
return;
}
let header = if online {
format!("ONLINE — {}", visible.len())
} else {
"OFFLINE — 0".to_string()
};
ui.horizontal(|ui| {
ui.add_space(12.0);
ui.label(
RichText::new(header)
.font(FontId::proportional(10.0))
.color(t::TEXT_MUTED)
.strong(),
);
});
ui.add_space(4.0);
ScrollArea::vertical()
.auto_shrink([false, true])
.show(ui, |ui| {
for nick in &visible {
member_row(ui, nick, online);
}
});
}
fn member_row(ui: &mut egui::Ui, nick: &str, online: bool) {
let (dot_color, text_color) = if online {
(t::SUCCESS, t::TEXT_PRIMARY)
} else {
(t::TEXT_DISABLED, t::TEXT_DISABLED)
};
Frame::NONE
.inner_margin(egui::Margin {
left: 12,
right: 8,
top: 2,
bottom: 2,
})
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
let init = nick
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string();
let avatar_color = if online {
nick_avatar_color(nick)
} else {
t::BG_ELEVATED
};
let (r, _) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 12.0, avatar_color);
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
&init,
egui::FontId::proportional(11.0),
Color32::from_rgb(15, 15, 15),
);
// Status dot
let (sd, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover());
ui.painter().circle_filled(sd.center(), 4.0, dot_color);
ui.label(
RichText::new(nick)
.font(FontId::proportional(12.0))
.color(text_color),
);
});
});
}
fn nick_avatar_color(nick: &str) -> egui::Color32 {
let colors = [
t::ACCENT,
t::SUCCESS,
t::WARNING,
t::INFO,
Color32::from_rgb(200, 100, 180),
Color32::from_rgb(120, 190, 180),
];
colors[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % colors.len()]
}

View file

@ -1,12 +1,4 @@
mod app;
pub mod chat;
pub mod connect;
pub mod keybind;
pub mod members;
pub mod settings;
pub mod sidebar;
pub mod bridge;
pub mod slint;
pub mod state;
pub mod theme;
pub mod titlebar;
pub use app::VnoxApp;
pub mod update;

View file

@ -1,78 +0,0 @@
mod nav;
mod network;
mod tabs;
mod widgets;
use crate::state::{SettingsPage, UiState};
use crate::theme as t;
use eframe::egui::{self, Align, Context, Frame, Layout, ScrollArea, Vec2, Window};
use vnox_client::identity::Identity;
pub use nav::show_nav;
pub fn show_window(ctx: &Context, s: &mut UiState, identity: &Identity) {
let open_id = egui::Id::new("settings_open_flag");
if !s.settings_open {
ctx.data_mut(|d| d.remove_temp::<bool>(open_id));
return;
}
if ctx.data_mut(|d| d.get_temp::<bool>(open_id)) != Some(true) {
ctx.data_mut(|d| d.insert_temp(open_id, true));
s.force_refresh_audio_devices();
}
s.refresh_audio_devices();
Window::new("Settings")
.collapsible(false)
.resizable(true)
.default_size([720.0, 520.0])
.min_size([520.0, 380.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG)
.shadow(egui::epaint::Shadow::NONE),
)
.show(ctx, |ui| {
ui.visuals_mut().override_text_color = Some(t::TEXT_PRIMARY);
let panel_h = ui.available_height().max(200.0);
ui.horizontal_top(|ui| {
ui.allocate_ui_with_layout(
Vec2::new(168.0, panel_h),
Layout::top_down(Align::LEFT),
|ui| nav::nav_panel(ui, s),
);
ui.separator();
ui.allocate_ui_with_layout(
Vec2::new(ui.available_width().max(280.0), panel_h),
Layout::top_down(Align::LEFT),
|ui| {
ScrollArea::vertical()
.id_salt("settings_content")
.max_height(panel_h)
.auto_shrink([false, true])
.show(ui, |ui| {
ui.set_min_width(320.0);
ui.add_space(12.0);
show_content(ui, s, identity);
ui.add_space(12.0);
});
},
);
});
});
}
pub fn show_content(ui: &mut egui::Ui, s: &mut UiState, id: &Identity) {
match &s.settings_page.clone() {
SettingsPage::Voice => tabs::voice(ui, s),
SettingsPage::AudioOutput => tabs::output(ui, s),
SettingsPage::Identity => tabs::show(ui, id, s),
SettingsPage::Network => network::network(ui, s),
SettingsPage::Overlay => network::overlay(ui, s),
SettingsPage::Appearance => tabs::appearance(ui, s),
SettingsPage::Keybinds => tabs::keybinds(ui, s),
SettingsPage::Plugins => tabs::plugins(ui),
SettingsPage::Advanced => tabs::advanced(ui, s),
}
}

View file

@ -1,125 +0,0 @@
use crate::state::{SettingsPage, UiState};
use crate::theme as t;
use eframe::egui::{self, Align, Context, FontId, Frame, Layout, ScrollArea, Vec2};
use super::widgets;
pub(super) fn nav_panel(ui: &mut egui::Ui, s: &mut UiState) {
Frame::NONE
.fill(t::BG_SURFACE)
.inner_margin(egui::Margin::symmetric(8, 12))
.show(ui, |ui| {
ui.vertical(|ui| {
ScrollArea::vertical()
.id_salt("settings_nav")
.max_height((ui.available_height() - 30.0).max(80.0))
.auto_shrink([false, true])
.show(ui, |ui| {
nav_group(ui, s, "account", &[(SettingsPage::Identity, "identity")]);
nav_group(
ui,
s,
"audio",
&[
(SettingsPage::Voice, "voice"),
(SettingsPage::AudioOutput, "audio output"),
],
);
nav_group(
ui,
s,
"network",
&[
(SettingsPage::Network, "network"),
(SettingsPage::Overlay, "overlay"),
],
);
nav_group(
ui,
s,
"app",
&[
(SettingsPage::Appearance, "appearance"),
(SettingsPage::Keybinds, "keybinds"),
(SettingsPage::Plugins, "plugins"),
],
);
nav_group(ui, s, "debug", &[(SettingsPage::Advanced, "advanced")]);
});
if back_btn(ui) {
s.settings_open = false;
}
});
});
}
pub fn show_nav(ctx: &Context, s: &mut UiState) {
egui::SidePanel::left("settings_nav")
.exact_width(160.0)
.resizable(false)
.frame(Frame::new().fill(t::BG_STRIP))
.show(ctx, |ui| {
ui.style_mut().spacing.item_spacing = Vec2::new(0.0, 1.0);
ui.add_space(10.0);
nav_group(ui, s, "account", &[(SettingsPage::Identity, "identity")]);
nav_group(
ui,
s,
"audio",
&[
(SettingsPage::Voice, "voice"),
(SettingsPage::AudioOutput, "audio output"),
],
);
nav_group(
ui,
s,
"network",
&[
(SettingsPage::Network, "network"),
(SettingsPage::Overlay, "overlay"),
],
);
nav_group(
ui,
s,
"app",
&[
(SettingsPage::Appearance, "appearance"),
(SettingsPage::Keybinds, "keybinds"),
(SettingsPage::Plugins, "plugins"),
],
);
nav_group(ui, s, "debug", &[(SettingsPage::Advanced, "advanced")]);
ui.with_layout(Layout::bottom_up(Align::LEFT), |ui| {
ui.add_space(8.0);
if back_btn(ui) {
s.settings_open = false;
}
});
});
}
fn nav_group(ui: &mut egui::Ui, s: &mut UiState, section: &str, items: &[(SettingsPage, &str)]) {
ui.add_space(4.0);
widgets::nav_section(ui, section);
for (page, label) in items {
if widgets::nav_item(ui, &s.settings_page == page, label) {
s.settings_page = page.clone();
}
}
}
fn back_btn(ui: &mut egui::Ui) -> bool {
ui.add_sized(
[ui.available_width().max(120.0), 28.0],
egui::Button::new(
egui::RichText::new("← back")
.font(FontId::monospace(11.0))
.color(t::TEXT_GHOST),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
}

View file

@ -1,132 +0,0 @@
use super::widgets::{badge, group_label, page_header, s_row, toggle};
use crate::state::UiState;
use crate::theme as t;
use eframe::egui::{self, Align, FontId, Frame, Layout, Margin, RichText, Stroke};
pub fn network(ui: &mut egui::Ui, s: &mut UiState) {
page_header(ui, "network", "LNEx protocol · relay · connection");
// Live status card
Frame::new()
.fill(t::BG_STRIP)
.stroke(Stroke::new(1.0, t::BORDER_FAINT))
.corner_radius(6u8)
.inner_margin(Margin::symmetric(12, 10))
.show(ui, |ui| {
status_row(ui, "protocol", |ui| badge(ui, "LNEx v1", t::ACCENT_SOFT));
status_row(ui, "voice transport", |ui| badge(ui, "UDP", t::BLUE));
status_row(ui, "control transport", |ui| badge(ui, "TCP", t::WARN));
status_row(ui, "encryption", |ui| {
badge(ui, "on · chacha20", t::SUCCESS)
});
status_row(ui, "rtt", |ui| {
let rtt = s
.rtt_ms
.map(|ms| format!("{ms}ms"))
.unwrap_or_else(|| "—".into());
let color = s
.rtt_ms
.map(|ms| {
if ms < 50 {
t::SUCCESS
} else if ms < 120 {
t::WARN
} else {
t::DANGER
}
})
.unwrap_or(t::TEXT_GHOST);
ui.label(
RichText::new(rtt)
.font(FontId::monospace(10.0))
.color(color),
);
});
status_row(ui, "packet loss", |ui| {
ui.label(
RichText::new("0.1%")
.font(FontId::monospace(10.0))
.color(t::SUCCESS),
);
});
});
ui.add_space(12.0);
group_label(ui, "relay");
s_row(
ui,
"use relay node",
Some("route through relay if direct fails"),
|ui| {
toggle(ui, &mut s.relay_enabled);
},
);
s_row(ui, "relay address", None, |ui| {
ui.add(
egui::TextEdit::singleline(&mut s.relay_address)
.font(FontId::monospace(10.0))
.text_color(t::TEXT_MUTED)
.desired_width(140.0),
);
});
s_row(
ui,
"auto relay selection",
Some("pick nearest by latency"),
|ui| {
toggle(ui, &mut s.auto_relay);
},
);
ui.add_space(8.0);
group_label(ui, "connection");
s_row(
ui,
"auto reconnect",
Some("retry with backoff after disconnect"),
|ui| {
toggle(ui, &mut s.auto_reconnect);
},
);
ui.add_space(8.0);
group_label(ui, "advanced");
s_row(ui, "UDP port", None, |ui| {
ui.add(
egui::TextEdit::singleline(&mut s.udp_port)
.font(FontId::monospace(10.0))
.text_color(t::TEXT_MUTED)
.desired_width(70.0),
);
});
s_row(ui, "force relay only", Some("disable direct p2p"), |ui| {
toggle(ui, &mut s.force_relay);
});
}
fn status_row(ui: &mut egui::Ui, key: &str, val: impl FnOnce(&mut egui::Ui)) {
ui.horizontal(|ui| {
ui.label(
RichText::new(key)
.font(FontId::monospace(10.0))
.color(t::TEXT_DEAD),
);
ui.with_layout(Layout::right_to_left(Align::Center), val);
});
ui.add_space(4.0);
}
pub fn overlay(ui: &mut egui::Ui, s: &mut UiState) {
page_header(ui, "overlay", "in-game HUD · speaking indicators");
group_label(ui, "general");
s_row(ui, "enable overlay", None, |ui| {
toggle(ui, &mut s.overlay_enabled);
});
s_row(ui, "show speaking users", None, |ui| {
toggle(ui, &mut s.overlay_speakers);
});
s_row(ui, "show latency", None, |ui| {
toggle(ui, &mut s.overlay_latency);
});
}

View file

@ -1,167 +0,0 @@
use crate::state::UiState;
use crate::theme as t;
use crate::ui::settings::widgets::{
badge, group_label, page_header, s_row, slider_row, small_select, toggle,
};
use eframe::egui::{self, FontId, RichText};
pub fn appearance(ui: &mut egui::Ui, s: &mut UiState) {
page_header(ui, "appearance", "theme · font · ui scale");
group_label(ui, "theme");
s_row(ui, "color scheme", None, |ui| {
let mut v = 0usize;
small_select(
ui,
"scheme",
&mut v,
&["hifi dark", "terminal green", "void"],
);
});
s_row(ui, "ui scale", None, |ui| {
slider_row(ui, &mut s.ui_scale, 80.0, 130.0, |v| {
format!("{}%", v as u32)
});
});
s_row(ui, "font", None, |ui| {
let mut v = 0usize;
small_select(
ui,
"font",
&mut v,
&["IBM Plex Mono", "Geist Mono", "Fira Code"],
);
});
}
pub fn plugins(ui: &mut egui::Ui) {
page_header(ui, "plugins", "installed · runtime: deno");
group_label(ui, "installed");
s_row(ui, "relay-switcher", Some("v0.1.2 · raven"), |ui| {
let mut v = true;
toggle(ui, &mut v);
});
s_row(ui, "music-bot", Some("v0.3.0 · community"), |ui| {
let mut v = false;
toggle(ui, &mut v);
});
}
pub fn advanced(ui: &mut egui::Ui, s: &mut UiState) {
page_header(ui, "advanced", "debug · logging · protocol internals");
group_label(ui, "logging");
s_row(ui, "log level", None, |ui| {
small_select(
ui,
"log_level",
&mut s.log_level,
&["info", "debug", "trace"],
);
});
s_row(ui, "log voice packets", Some("verbose · high cpu"), |ui| {
toggle(ui, &mut s.log_voice_packets);
});
ui.add_space(8.0);
group_label(ui, "protocol");
s_row(ui, "LNEx version", None, |ui| {
badge(ui, "v1", t::ACCENT_SOFT);
});
s_row(ui, "show packet stats", None, |ui| {
toggle(ui, &mut s.show_packet_stats);
});
s_row(
ui,
"disable encryption",
Some("debug only · dangerous"),
|ui| {
ui.label(
RichText::new("!")
.font(FontId::monospace(10.0))
.color(t::DANGER),
);
ui.add_space(4.0);
toggle(ui, &mut s.disable_encryption);
},
);
}
pub fn keybinds(ui: &mut egui::Ui, s: &mut UiState) {
page_header(ui, "keybinds", "global hotkeys");
group_label(ui, "voice");
kb_row(
ui,
"push-to-talk",
&mut s.kb_ptt,
&mut s.kb_listening,
"ptt",
);
kb_row(
ui,
"mute toggle",
&mut s.kb_mute,
&mut s.kb_listening,
"mute",
);
kb_row(
ui,
"deafen toggle",
&mut s.kb_deafen,
&mut s.kb_listening,
"deafen",
);
ui.add_space(8.0);
group_label(ui, "overlay");
kb_row(
ui,
"toggle overlay",
&mut s.kb_overlay,
&mut s.kb_listening,
"overlay",
);
}
fn kb_row(
ui: &mut egui::Ui,
label: &str,
val: &mut String,
listening: &mut Option<String>,
listen_key: &str,
) {
s_row(ui, label, None, |ui| {
let is_listening = listening.as_deref() == Some(listen_key);
if is_listening {
if let Some(binding) = crate::keybind::listen_pressed(ui.ctx()) {
*val = binding;
*listening = None;
}
let btn = egui::Button::new(
egui::RichText::new("Listening… press a key")
.font(egui::FontId::monospace(10.0))
.color(egui::Color32::WHITE),
)
.fill(crate::theme::ACCENT)
.corner_radius(crate::theme::R_SM)
.min_size(egui::vec2(120.0, 20.0));
if ui.add(btn).clicked() {
*listening = None;
}
} else {
ui.add(
egui::TextEdit::singleline(val)
.font(egui::FontId::monospace(10.0))
.text_color(crate::theme::TEXT_MUTED)
.desired_width(90.0),
);
let rec = egui::Button::new(
egui::RichText::new("●")
.font(egui::FontId::monospace(9.0))
.color(crate::theme::ERROR),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false)
.min_size(egui::vec2(16.0, 16.0));
if ui.add(rec).on_hover_text("record keybind").clicked() {
*listening = Some(listen_key.to_string());
}
}
});
}

View file

@ -1,150 +0,0 @@
use crate::state::UiState;
use crate::theme as t;
use crate::ui::settings::widgets::{
badge, device_meta_row, group_label, page_header, s_row, slider_row, small_select,
small_select_labels, toggle,
};
use eframe::egui::{self, FontId, RichText, Stroke, Vec2};
use vnox_client::audio::devices::DeviceLabel;
fn selected_meta(index: usize, metas: &[DeviceLabel]) -> Option<&DeviceLabel> {
if index == 0 {
return None;
}
metas.get(index.saturating_sub(1))
}
pub fn voice(ui: &mut egui::Ui, s: &mut UiState) {
page_header(ui, "voice", "input device, encoding, push-to-talk");
group_label(ui, "input device");
s_row(ui, "microphone", None, |ui| {
small_select_labels(ui, "mic_dev", &mut s.mic_device, &s.input_device_labels);
});
device_meta_row(ui, selected_meta(s.mic_device, &s.input_device_metas));
s_row(ui, "input volume", None, |ui| {
slider_row(ui, &mut s.input_volume, 0.0, 100.0, |v| {
format!("{}%", v as u32)
});
});
s_row(ui, "noise suppression", Some("rnnoise · cpu ~2%"), |ui| {
toggle(ui, &mut s.noise_suppress);
});
s_row(ui, "echo cancellation", None, |ui| {
toggle(ui, &mut s.echo_cancel);
});
ui.add_space(8.0);
group_label(ui, "activation");
s_row(ui, "mode", None, |ui| {
small_select(
ui,
"vad_mode",
&mut s.vad_mode,
&["push-to-talk", "voice activity", "always on"],
);
});
if s.vad_mode == 1 {
s_row(
ui,
"vad threshold",
Some("voice activity sensitivity"),
|ui| {
slider_row(ui, &mut s.vad_threshold, 0.0, 100.0, |v| {
format!("{}%", v as u32)
});
},
);
}
ui.add_space(8.0);
group_label(ui, "codec");
s_row(ui, "encoder", Some("opus · low latency mode"), |ui| {
badge(ui, "opus", t::ACCENT_SOFT);
});
s_row(ui, "bitrate", Some("opus 8–128 kbps"), |ui| {
ui.horizontal(|ui| {
slider_row(ui, &mut s.voice_bitrate, 8.0, 128.0, |v| {
format!("{}k", v as u32)
});
let presets = [32, 64, 96, 128usize];
for &p in &presets {
let pf = p as f32;
let selected = (s.voice_bitrate - pf).abs() < 1.0;
let resp = ui.add_sized(
Vec2::new(28.0, 20.0),
egui::Button::new(
RichText::new(format!("{}", p))
.font(FontId::monospace(8.0))
.color(if selected { t::ACCENT } else { t::TEXT_MUTED }),
)
.fill(if selected {
egui::Color32::from_rgba_premultiplied(
t::ACCENT.r(),
t::ACCENT.g(),
t::ACCENT.b(),
25,
)
} else {
egui::Color32::from_rgb(23, 23, 23)
})
.stroke(Stroke::new(1.0, t::BORDER_SUBTLE))
.corner_radius(3u8),
);
if resp.clicked() {
s.voice_bitrate = pf;
}
}
});
let effective = (s.voice_bitrate as u32).clamp(8, 128);
ui.horizontal(|ui| {
ui.add_space(4.0);
ui.label(
RichText::new(format!("effective: {} kbps", effective))
.font(FontId::monospace(9.0))
.color(t::TEXT_DEAD),
);
});
});
s_row(
ui,
"packet interval",
Some("lower = less latency, more cpu"),
|ui| {
small_select(
ui,
"pkt_interval",
&mut s.packet_interval,
&["10ms", "20ms", "40ms"],
);
},
);
}
pub fn output(ui: &mut egui::Ui, s: &mut UiState) {
page_header(ui, "audio output", "output device · volume · jitter buffer");
group_label(ui, "output device");
s_row(ui, "device", None, |ui| {
small_select_labels(ui, "out_dev", &mut s.output_device, &s.output_device_labels);
});
device_meta_row(ui, selected_meta(s.output_device, &s.output_device_metas));
s_row(ui, "output volume", None, |ui| {
slider_row(ui, &mut s.output_volume, 0.0, 100.0, |v| {
format!("{}%", v as u32)
});
});
ui.add_space(8.0);
group_label(ui, "jitter buffer");
s_row(
ui,
"buffer size",
Some("lower = less delay, more glitches"),
|ui| {
small_select(ui, "jitter", &mut s.jitter_size, &["20ms", "40ms", "80ms"]);
},
);
s_row(
ui,
"adaptive buffer",
Some("auto-adjust on packet loss"),
|ui| {
toggle(ui, &mut s.adaptive_buffer);
},
);
}

View file

@ -1,629 +0,0 @@
use crate::identity::{self, Identity};
use crate::theme as t;
use crate::ui::settings::widgets::{group_label, id_button, page_header, s_row, toggle};
use crate::ui::state::UiState;
use eframe::egui::{self, Color32, FontId, Frame, Margin, RichText, Stroke, Vec2};
pub fn show(ui: &mut egui::Ui, identity: &Identity, s: &mut UiState) {
page_header(ui, "identity", "your local keypair · no account required");
Frame::new()
.fill(t::BG_PANEL)
.stroke(Stroke::new(1.0, t::BORDER))
.corner_radius(7u8)
.inner_margin(Margin::same(12))
.show(ui, |ui| {
ui.horizontal(|ui| {
let init: String = identity
.nickname
.chars()
.take(2)
.collect::<String>()
.to_uppercase();
let av = egui::Button::new(
RichText::new(&init)
.font(FontId::monospace(11.0))
.color(t::ACCENT),
)
.fill(Color32::from_rgba_premultiplied(255, 107, 53, 18))
.stroke(Stroke::new(
1.0,
Color32::from_rgba_premultiplied(255, 107, 53, 38),
))
.corner_radius(8u8)
.min_size(Vec2::splat(36.0));
ui.add_enabled(false, av);
ui.add_space(6.0);
ui.vertical(|ui| {
ui.label(
RichText::new(&identity.nickname)
.font(FontId::monospace(12.0))
.color(t::TEXT_SECONDARY)
.strong(),
);
ui.label(
RichText::new(format!("pub: {}…", &identity.pubkey_hex[..16]))
.font(FontId::monospace(9.0))
.color(t::TEXT_GHOST),
);
});
});
ui.add_space(10.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 5.0;
if id_button(ui, "copy pubkey") {
ui.ctx().copy_text(identity.pubkey_hex.clone());
}
if id_button(ui, "set passphrase") {
s.vault_set_open = true;
s.vault_passphrase_input.clear();
s.vault_passphrase_confirm.clear();
s.vault_error = None;
}
if id_button(ui, "remove passphrase") {
s.vault_remove_open = true;
s.vault_passphrase_input.clear();
s.vault_error = None;
}
if id_button(ui, "export keyfile") {
s.export_keyfile_open = true;
s.export_keyfile_pass.clear();
s.export_keyfile_confirm.clear();
s.export_keyfile_output.clear();
s.export_keyfile_error = None;
}
if id_button(ui, "import keyfile") {
s.import_keyfile_open = true;
s.import_keyfile_input.clear();
s.import_keyfile_pass.clear();
s.import_keyfile_error = None;
}
id_button(ui, "change nickname");
id_button(ui, "rotate keypair");
});
});
// Vault status block.
ui.add_space(10.0);
group_label(ui, "vault status");
let is_encrypted = identity::is_vault_encrypted();
Frame::new()
.fill(if is_encrypted { t::BG_PANEL } else { t::BG_ELEVATED })
.stroke(Stroke::new(
1.0,
if is_encrypted { t::SUCCESS } else { t::BORDER },
))
.corner_radius(7u8)
.inner_margin(Margin::same(12))
.show(ui, |ui| {
ui.horizontal(|ui| {
let (label, color) = if is_encrypted {
("encrypted (Argon2id + ChaCha20-Poly1305)", t::SUCCESS)
} else {
("plain JSON — passphrase not set", t::WARNING)
};
// Drawn lock/unlock icon.
let (icon_rect, _) = ui.allocate_exact_size(
egui::vec2(18.0, 18.0),
egui::Sense::hover(),
);
let icon_fn = if is_encrypted {
crate::theme::icons::lock
} else {
crate::theme::icons::unlock
};
icon_fn(ui.painter(), icon_rect, color);
ui.label(
RichText::new(label)
.font(FontId::proportional(12.0))
.color(color)
.strong(),
);
});
ui.add_space(4.0);
ui.label(
RichText::new(if is_encrypted {
"Your private key is encrypted at rest. The vault is unlocked in memory while VNOX is running."
} else {
"Anyone with filesystem access can read your private key. Set a passphrase to encrypt it."
})
.font(FontId::proportional(11.0))
.color(t::TEXT_MUTED),
);
});
ui.add_space(10.0);
group_label(ui, "backup");
s_row(
ui,
"seed phrase backup",
Some("24-word recovery · never share"),
|ui| {
let mut v = false;
toggle(ui, &mut v);
},
);
s_row(
ui,
"export to file",
Some(".vnox keyfile · encrypted"),
|ui| {
let _ = id_button(ui, "export");
},
);
ui.add_space(8.0);
group_label(ui, "permissions");
s_row(
ui,
"show identity to strangers",
Some("pubkey visible on federated nodes"),
|ui| {
let mut v = true;
toggle(ui, &mut v);
},
);
// Set-passphrase modal.
if s.vault_set_open {
show_set_passphrase_modal(ui.ctx(), identity, s);
}
// Remove-passphrase modal.
if s.vault_remove_open {
show_remove_passphrase_modal(ui.ctx(), identity, s);
}
// Export keyfile modal.
if s.export_keyfile_open {
show_export_keyfile_modal(ui.ctx(), identity, s);
}
// Import keyfile modal.
if s.import_keyfile_open {
show_import_keyfile_modal(ui.ctx(), s);
}
}
fn show_set_passphrase_modal(ctx: &egui::Context, identity: &Identity, s: &mut UiState) {
egui::Window::new("Set Identity Passphrase")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(360.0);
ui.label(
RichText::new("Pick a strong passphrase. It will be used to encrypt your identity keypair at rest (Argon2id + ChaCha20-Poly1305).")
.font(FontId::proportional(11.0))
.color(t::TEXT_MUTED),
);
ui.add_space(4.0);
ui.label(
RichText::new("! There is no recovery — if you forget the passphrase, the keypair is lost.")
.font(FontId::proportional(11.0))
.color(t::WARNING),
);
ui.add_space(10.0);
ui.label(RichText::new("Passphrase").font(FontId::proportional(12.0)).color(t::TEXT_PRIMARY));
let p1 = ui.add(
egui::TextEdit::singleline(&mut s.vault_passphrase_input)
.password(true)
.font(FontId::monospace(12.0))
.desired_width(320.0),
);
ui.add_space(6.0);
ui.label(RichText::new("Confirm").font(FontId::proportional(12.0)).color(t::TEXT_PRIMARY));
let p2 = ui.add(
egui::TextEdit::singleline(&mut s.vault_passphrase_confirm)
.password(true)
.font(FontId::monospace(12.0))
.desired_width(320.0),
);
ui.add_space(10.0);
let mismatch = !s.vault_passphrase_input.is_empty()
&& s.vault_passphrase_input != s.vault_passphrase_confirm;
if mismatch {
ui.label(
RichText::new("passphrases do not match")
.font(FontId::proportional(11.0))
.color(t::ERROR),
);
}
if s.vault_passphrase_input.len() < 8 && !s.vault_passphrase_input.is_empty() {
ui.label(
RichText::new("! use at least 8 characters")
.font(FontId::proportional(11.0))
.color(t::WARNING),
);
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new(
RichText::new("Cancel")
.font(FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.vault_set_open = false;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let can_apply = !s.vault_passphrase_input.is_empty()
&& s.vault_passphrase_input == s.vault_passphrase_confirm
&& s.vault_passphrase_input.len() >= 8;
let btn = egui::Button::new(
RichText::new("Encrypt & Save")
.font(FontId::proportional(12.0))
.color(egui::Color32::WHITE),
)
.fill(t::ACCENT)
.stroke(egui::Stroke::NONE)
.corner_radius(t::R_SM)
.min_size(egui::vec2(120.0, 28.0));
if ui.add_enabled(can_apply, btn).clicked() {
match identity::save(identity, Some(&s.vault_passphrase_input)) {
Ok(()) => {
s.vault_set_open = false;
s.vault_passphrase_input.clear();
s.vault_passphrase_confirm.clear();
}
Err(e) => {
s.vault_error = Some(format!("save failed: {e}"));
}
}
}
});
});
if let Some(ref err) = s.vault_error {
ui.add_space(4.0);
ui.label(RichText::new(err).font(FontId::proportional(11.0)).color(t::ERROR));
}
if (p1.lost_focus() || p2.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape))
{
s.vault_set_open = false;
}
});
}
fn show_remove_passphrase_modal(ctx: &egui::Context, identity: &Identity, s: &mut UiState) {
egui::Window::new("Remove Passphrase")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(360.0);
ui.label(
RichText::new("! This will store your identity as plain JSON on disk. Anyone with filesystem access will be able to read your private key.")
.font(FontId::proportional(11.0))
.color(t::WARNING),
);
ui.add_space(8.0);
ui.label(
RichText::new("Type REMOVE to confirm.")
.font(FontId::proportional(12.0))
.color(t::TEXT_PRIMARY),
);
ui.add_space(4.0);
let resp = ui.add(
egui::TextEdit::singleline(&mut s.vault_passphrase_input)
.font(FontId::monospace(12.0))
.desired_width(320.0),
);
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new(
RichText::new("Cancel")
.font(FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.vault_remove_open = false;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let can_apply = s.vault_passphrase_input == "REMOVE";
let btn = egui::Button::new(
RichText::new("Remove Passphrase")
.font(FontId::proportional(12.0))
.color(egui::Color32::WHITE),
)
.fill(t::ERROR)
.stroke(egui::Stroke::NONE)
.corner_radius(t::R_SM)
.min_size(egui::vec2(140.0, 28.0));
if ui.add_enabled(can_apply, btn).clicked() {
match identity::save(identity, None) {
Ok(()) => {
s.vault_remove_open = false;
s.vault_passphrase_input.clear();
}
Err(e) => {
s.vault_error = Some(format!("save failed: {e}"));
}
}
}
});
});
if let Some(ref err) = s.vault_error {
ui.add_space(4.0);
ui.label(RichText::new(err).font(FontId::proportional(11.0)).color(t::ERROR));
}
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
s.vault_remove_open = false;
}
});
}
fn show_export_keyfile_modal(ctx: &egui::Context, identity: &Identity, s: &mut UiState) {
egui::Window::new("Export Identity Keyfile")
.collapsible(false)
.resizable(true)
.default_size([440.0, 380.0])
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(420.0);
ui.label(
RichText::new("Pick a passphrase to encrypt the keyfile. Anyone with the keyfile and the passphrase can impersonate you — share carefully.")
.font(FontId::proportional(11.0))
.color(t::TEXT_MUTED),
);
ui.add_space(8.0);
ui.label(RichText::new("Passphrase (optional — empty = plain JSON)").font(FontId::proportional(12.0)).color(t::TEXT_PRIMARY));
ui.add(
egui::TextEdit::singleline(&mut s.export_keyfile_pass)
.password(true)
.font(FontId::monospace(12.0))
.desired_width(380.0),
);
ui.add_space(6.0);
ui.label(RichText::new("Confirm").font(FontId::proportional(12.0)).color(t::TEXT_PRIMARY));
ui.add(
egui::TextEdit::singleline(&mut s.export_keyfile_confirm)
.password(true)
.font(FontId::monospace(12.0))
.desired_width(380.0),
);
let mismatch = !s.export_keyfile_pass.is_empty()
&& s.export_keyfile_pass != s.export_keyfile_confirm;
if mismatch {
ui.label(
RichText::new("passphrases do not match")
.font(FontId::proportional(11.0))
.color(t::ERROR),
);
}
ui.add_space(8.0);
let can_generate = !mismatch;
let gen_btn = egui::Button::new(
RichText::new("Generate keyfile")
.font(FontId::proportional(12.0))
.color(egui::Color32::WHITE),
)
.fill(t::ACCENT)
.stroke(egui::Stroke::NONE)
.corner_radius(t::R_SM)
.min_size(egui::vec2(120.0, 28.0));
if ui.add_enabled(can_generate, gen_btn).clicked() {
let pass = if s.export_keyfile_pass.is_empty() {
None
} else {
Some(s.export_keyfile_pass.as_str())
};
match identity::export_keyfile(identity, pass) {
Ok(text) => s.export_keyfile_output = text,
Err(e) => s.export_keyfile_error = Some(format!("export failed: {e}")),
}
}
if let Some(ref err) = s.export_keyfile_error {
ui.label(RichText::new(err).font(FontId::proportional(11.0)).color(t::ERROR));
}
ui.add_space(6.0);
if !s.export_keyfile_output.is_empty() {
ui.label(
RichText::new("Keyfile (copy and save to a .vnoxkey file):")
.font(FontId::proportional(11.0))
.color(t::TEXT_MUTED),
);
egui::ScrollArea::vertical()
.max_height(160.0)
.show(ui, |ui| {
let mut buf = s.export_keyfile_output.clone();
ui.add(
egui::TextEdit::multiline(&mut buf)
.font(FontId::monospace(10.0))
.desired_width(380.0)
.interactive(true),
);
});
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new(
RichText::new("Copy")
.font(FontId::proportional(11.0))
.color(t::ACCENT),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
ui.ctx().copy_text(s.export_keyfile_output.clone());
}
if ui
.add(
egui::Button::new(
RichText::new("Save to disk")
.font(FontId::proportional(11.0))
.color(t::ACCENT),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
let path = dirs::document_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(format!("vnox-{}-{}.vnoxkey", identity.nickname, &identity.pubkey_hex[..8]));
match std::fs::write(&path, &s.export_keyfile_output) {
Ok(()) => {
s.export_keyfile_error = Some(format!("saved to: {}", path.display()));
}
Err(e) => {
s.export_keyfile_error = Some(format!("save failed: {e}"));
}
}
}
});
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new(
RichText::new("Close")
.font(FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.export_keyfile_open = false;
}
});
});
}
fn show_import_keyfile_modal(ctx: &egui::Context, s: &mut UiState) {
egui::Window::new("Import Identity Keyfile")
.collapsible(false)
.resizable(true)
.default_size([440.0, 360.0])
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(420.0);
ui.label(
RichText::new("! Importing a keyfile will replace your current identity. Back up your current keypair first if you want to keep it.")
.font(FontId::proportional(11.0))
.color(t::WARNING),
);
ui.add_space(8.0);
ui.label(
RichText::new("Paste keyfile JSON here:")
.font(FontId::proportional(12.0))
.color(t::TEXT_PRIMARY),
);
egui::ScrollArea::vertical()
.max_height(140.0)
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut s.import_keyfile_input)
.font(FontId::monospace(10.0))
.desired_width(380.0)
.hint_text(RichText::new(r#"{"version":1,"scheme":"..."}"#).color(t::TEXT_DISABLED)),
);
});
ui.add_space(6.0);
ui.label(
RichText::new("Passphrase (leave empty for plain keyfiles)")
.font(FontId::proportional(12.0))
.color(t::TEXT_PRIMARY),
);
ui.add(
egui::TextEdit::singleline(&mut s.import_keyfile_pass)
.password(true)
.font(FontId::monospace(12.0))
.desired_width(380.0),
);
ui.add_space(8.0);
let can_import = !s.import_keyfile_input.trim().is_empty();
let import_btn = egui::Button::new(
RichText::new("Import & Replace Identity")
.font(FontId::proportional(12.0))
.color(egui::Color32::WHITE),
)
.fill(t::ERROR)
.stroke(egui::Stroke::NONE)
.corner_radius(t::R_SM)
.min_size(egui::vec2(180.0, 28.0));
if ui.add_enabled(can_import, import_btn).clicked() {
let pass = if s.import_keyfile_pass.is_empty() {
None
} else {
Some(s.import_keyfile_pass.as_str())
};
match identity::import_keyfile(s.import_keyfile_input.trim(), pass) {
Ok(identity) => {
// Persist imported identity (without passphrase for now —
// the user can set one separately).
match identity::save(&identity, None) {
Ok(()) => {
s.import_keyfile_error = Some(
"imported — restart VNOX to use the new identity".into(),
);
}
Err(e) => {
s.import_keyfile_error = Some(format!("save failed: {e}"));
}
}
}
Err(e) => {
s.import_keyfile_error = Some(format!("import failed: {e}"));
}
}
}
if let Some(ref err) = s.import_keyfile_error {
ui.label(RichText::new(err).font(FontId::proportional(11.0)).color(t::TEXT_MUTED));
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new(
RichText::new("Close")
.font(FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.import_keyfile_open = false;
}
});
});
}

View file

@ -1,7 +0,0 @@
mod app;
mod audio;
mod identity;
pub use app::{advanced, appearance, keybinds, plugins};
pub use audio::{output, voice};
pub use identity::show;

View file

@ -1,128 +0,0 @@
use crate::theme as t;
use eframe::egui::{self, Color32, FontId, Frame, Margin, RichText, Sense, Stroke, Vec2};
pub fn toggle(ui: &mut egui::Ui, value: &mut bool) {
let (rect, resp) = ui.allocate_exact_size(Vec2::new(30.0, 16.0), Sense::click());
if resp.clicked() {
*value = !*value;
}
let painter = ui.painter();
let bg = if *value {
Color32::from_rgba_premultiplied(255, 107, 53, 38)
} else {
t::BG_ELEVATED
};
let bdr = if *value {
Color32::from_rgb(100, 50, 25)
} else {
t::BORDER_SUBTLE
};
painter.rect(
rect,
8u8,
bg,
Stroke::new(1.0, bdr),
egui::StrokeKind::Inside,
);
let kx = if *value {
rect.max.x - 7.0
} else {
rect.min.x + 7.0
};
let kc = if *value {
t::ACCENT
} else {
Color32::from_rgb(45, 45, 45)
};
painter.circle_filled(egui::pos2(kx, rect.center().y), 5.0, kc);
}
pub fn small_select_labels(ui: &mut egui::Ui, id: &str, value: &mut usize, options: &[String]) {
let fallback = "?".to_string();
let selected = options.get(*value).unwrap_or(&fallback);
egui::ComboBox::from_id_salt(id)
.selected_text(
RichText::new(selected.as_str())
.font(FontId::monospace(10.0))
.color(t::TEXT_MUTED),
)
.width(220.0)
.show_ui(ui, |ui| {
for (i, opt) in options.iter().enumerate() {
ui.selectable_value(
value,
i,
RichText::new(opt.as_str())
.font(FontId::monospace(10.0))
.color(t::TEXT_SECONDARY),
);
}
});
}
pub fn small_select(ui: &mut egui::Ui, id: &str, value: &mut usize, options: &[&str]) {
egui::ComboBox::from_id_salt(id)
.selected_text(
RichText::new(options.get(*value).copied().unwrap_or("?"))
.font(FontId::monospace(10.0))
.color(t::TEXT_MUTED),
)
.width(110.0)
.show_ui(ui, |ui| {
for (i, opt) in options.iter().enumerate() {
ui.selectable_value(
value,
i,
RichText::new(*opt)
.font(FontId::monospace(10.0))
.color(t::TEXT_SECONDARY),
);
}
});
}
pub fn slider_row(
ui: &mut egui::Ui,
val: &mut f32,
min: f32,
max: f32,
fmt: impl Fn(f32) -> String,
) {
ui.horizontal(|ui| {
ui.spacing_mut().slider_width = 90.0;
ui.add(
egui::Slider::new(val, min..=max)
.show_value(false)
.trailing_fill(true),
);
ui.add_space(4.0);
ui.label(
RichText::new(fmt(*val))
.font(FontId::monospace(10.0))
.color(t::TEXT_DIM),
);
});
}
pub fn badge(ui: &mut egui::Ui, text: &str, color: Color32) {
Frame::new()
.fill(Color32::from_rgba_premultiplied(
color.r(),
color.g(),
color.b(),
25,
))
.stroke(Stroke::new(
1.0,
Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), 50),
))
.corner_radius(3u8)
.inner_margin(Margin::symmetric(6, 2))
.show(ui, |ui| {
ui.label(
RichText::new(text)
.font(FontId::monospace(9.0))
.color(color),
);
});
}

View file

@ -1,90 +0,0 @@
use crate::theme as t;
use eframe::egui::{self, Align, Color32, FontId, Layout, RichText, Stroke};
pub fn page_header(ui: &mut egui::Ui, title: &str, sub: &str) {
ui.label(
RichText::new(title)
.font(FontId::monospace(13.0))
.color(t::TEXT_SECONDARY)
.strong(),
);
ui.add_space(2.0);
ui.label(
RichText::new(sub)
.font(FontId::monospace(9.0))
.color(t::TEXT_DEAD),
);
ui.add_space(14.0);
}
pub fn group_label(ui: &mut egui::Ui, label: &str) {
ui.add_space(2.0);
ui.label(
RichText::new(label.to_uppercase())
.font(FontId::monospace(9.0))
.color(t::TEXT_DEAD),
);
ui.add(egui::Separator::default().spacing(6.0));
ui.add_space(2.0);
}
pub fn s_row(
ui: &mut egui::Ui,
label: &str,
desc: Option<&str>,
content: impl FnOnce(&mut egui::Ui),
) {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_min_width(200.0);
ui.label(
RichText::new(label)
.font(FontId::monospace(11.0))
.color(t::TEXT_DIM),
);
if let Some(d) = desc {
ui.label(
RichText::new(d)
.font(FontId::monospace(9.0))
.color(t::TEXT_DEAD),
);
}
});
ui.with_layout(Layout::right_to_left(Align::Center), content);
});
ui.add_space(4.0);
ui.add(egui::Separator::default().horizontal().spacing(0.0));
ui.add_space(4.0);
}
pub fn device_meta_row(ui: &mut egui::Ui, meta: Option<&vnox_client::audio::devices::DeviceLabel>) {
if let Some(m) = meta {
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.add_space(4.0);
super::controls::badge(ui, &m.host_api, Color32::from_rgb(120, 180, 255));
if m.channels > 0 {
super::controls::badge(
ui,
&format!("{}ch", m.channels),
Color32::from_rgb(180, 200, 120),
);
}
});
ui.add_space(2.0);
}
}
pub fn id_button(ui: &mut egui::Ui, label: &str) -> bool {
ui.add(
egui::Button::new(
RichText::new(label)
.font(FontId::monospace(9.0))
.color(t::TEXT_MUTED),
)
.fill(Color32::from_rgb(23, 23, 23))
.stroke(Stroke::new(1.0, t::BORDER_SUBTLE))
.corner_radius(4u8),
)
.clicked()
}

View file

@ -1,7 +0,0 @@
mod controls;
mod layout;
mod nav;
pub use controls::{badge, slider_row, small_select, small_select_labels, toggle};
pub use layout::{device_meta_row, group_label, id_button, page_header, s_row};
pub use nav::{nav_item, nav_section};

View file

@ -1,46 +0,0 @@
use crate::theme as t;
use eframe::egui::{self, FontId, RichText};
pub fn nav_item(ui: &mut egui::Ui, is_active: bool, label: &str) -> bool {
let text_color = if is_active {
t::ACCENT_SOFT
} else {
t::TEXT_GHOST
};
let fill = if is_active {
t::ACCENT_10
} else {
egui::Color32::TRANSPARENT
};
let stroke = if is_active {
egui::Stroke::new(1.0, t::BORDER_ACCENT)
} else {
egui::Stroke::NONE
};
ui.add_sized(
[ui.available_width().max(120.0), 26.0],
egui::Button::new(
RichText::new(label)
.font(FontId::monospace(11.0))
.color(text_color),
)
.fill(fill)
.stroke(stroke)
.corner_radius(t::R_SM),
)
.clicked()
}
pub fn nav_section(ui: &mut egui::Ui, label: &str) {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(
RichText::new(label.to_uppercase())
.font(FontId::monospace(9.0))
.color(t::TEXT_DEAD),
);
});
ui.add_space(2.0);
}

View file

@ -1,142 +0,0 @@
use eframe::egui::{self, Frame};
use crate::theme as t;
use vnox_client::net::{NetCommand, NetHandle};
/// Channel row with optional context menu for deletion.
/// When `net` is provided and the channel is not a default one, a "Delete channel"
/// entry appears in the right-click context menu.
pub(super) fn ch_row_with_menu(
ui: &mut egui::Ui,
icon: &str,
name: &str,
active: bool,
badge: Option<&str>,
live: bool,
net: Option<&NetHandle>,
) -> bool {
let bg = if active {
t::ACCENT_10
} else {
egui::Color32::TRANSPARENT
};
let stroke = if active {
egui::Stroke::new(1.0, t::BORDER_ACCENT)
} else {
egui::Stroke::NONE
};
let text_c = if active {
t::ACCENT_SOFT
} else {
t::TEXT_MUTED
};
let resp = Frame::NONE
.fill(bg)
.stroke(stroke)
.corner_radius(t::R_SM)
.inner_margin(egui::Margin::symmetric(8, 3))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(icon)
.color(if active { t::ACCENT } else { t::TEXT_MUTED })
.size(13.0),
);
ui.label(egui::RichText::new(name).color(text_c).size(13.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if live {
let (r, _) =
ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 3.0, t::SUCCESS);
} else if let Some(b) = badge {
ui.label(
egui::RichText::new(b)
.font(egui::FontId::proportional(10.0))
.color(t::TEXT_DISABLED),
);
}
});
});
})
.response
.interact(egui::Sense::click());
// Right-click context menu — offer deletion for non-default channels.
if let Some(net) = net
&& name != "general"
&& name != "voice"
{
resp.context_menu(|ui| {
if ui
.add(
egui::Button::new(
egui::RichText::new("Delete channel")
.font(egui::FontId::proportional(12.0))
.color(t::ERROR),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
let net = net.clone();
let ch_id = name.to_string();
tokio::spawn(async move {
net.send(NetCommand::ChannelDelete { channel_id: ch_id })
.await;
});
ui.close();
}
});
}
resp.clicked()
}
pub(super) fn member_row(ui: &mut egui::Ui, nick: &str) {
Frame::NONE
.inner_margin(egui::Margin {
left: 28,
right: 8,
top: 4,
bottom: 4,
})
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
let init = nick
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string();
let (r, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover());
ui.painter()
.circle_filled(r.center(), 8.0, nick_color(nick));
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
&init,
egui::FontId::proportional(8.0),
egui::Color32::from_rgb(15, 15, 15),
);
ui.label(
egui::RichText::new(nick)
.font(egui::FontId::proportional(11.5))
.color(t::TEXT_MUTED),
);
});
});
}
fn nick_color(nick: &str) -> egui::Color32 {
let colors = [
t::ACCENT,
t::SUCCESS,
t::WARNING,
t::INFO,
egui::Color32::from_rgb(200, 100, 180),
];
colors[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % colors.len()]
}

View file

@ -1,327 +0,0 @@
mod items;
use eframe::egui::{self, Frame};
use vnox_client::net::{NetCommand, NetHandle};
use crate::state::{Channel, UiState, VoiceUiState};
use crate::theme as t;
pub fn show(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
if matches!(s.conn, crate::state::ConnState::Connected { .. }) && s.channels.is_empty() {
s.seed_default_channels();
if s.active_channel.is_none() {
s.active_channel = Some("general".into());
}
}
if let Some(guild_id) = &s.active_guild_id.clone()
&& let Some(g) = s.guilds.iter().find(|g| g.guild_id == *guild_id)
{
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(&g.name)
.font(egui::FontId::proportional(14.0))
.color(t::TEXT_PRIMARY),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
Frame::NONE
.inner_margin(egui::Margin::symmetric(4, 2))
.show(ui, |ui| {
ui.label(
egui::RichText::new(format!("{} members", g.member_count))
.font(egui::FontId::proportional(10.0))
.color(t::TEXT_MUTED),
);
});
// Create channel button ("+").
if ui
.add(
egui::Button::new(
egui::RichText::new("+")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_SM)
.min_size(egui::vec2(20.0, 18.0)),
)
.on_hover_text("create a channel")
.clicked()
{
s.create_channel_open = true;
s.create_channel_input.clear();
}
let guild_id = guild_id.clone();
if ui
.add(
egui::Button::new(
egui::RichText::new("Leave")
.font(egui::FontId::proportional(10.0))
.color(t::TEXT_MUTED),
)
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_SM),
)
.on_hover_text("leave this guild")
.clicked()
{
s.active_guild_id = None;
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::GuildLeave { guild_id }).await;
});
}
});
});
ui.add_space(2.0);
ui.separator();
ui.add_space(4.0);
} else {
// Home (no guild) — show a "Create Channel" button in the channel header.
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("CHANNELS")
.font(egui::FontId::proportional(10.0))
.color(t::TEXT_MUTED)
.strong(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(
egui::RichText::new("+")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_SM)
.min_size(egui::vec2(20.0, 18.0)),
)
.on_hover_text("create a channel")
.clicked()
{
s.create_channel_open = true;
s.create_channel_input.clear();
}
});
});
ui.add_space(2.0);
ui.separator();
ui.add_space(4.0);
}
if s.create_channel_open {
show_create_channel_popup(ui.ctx(), s, net);
}
let text_chs: Vec<Channel> = s
.channels
.iter()
.filter(|c| c.kind == "text")
.cloned()
.collect();
let voice_chs: Vec<Channel> = s
.channels
.iter()
.filter(|c| c.kind == "voice")
.cloned()
.collect();
if !text_chs.is_empty() {
cat_label(ui, s, "text");
if !s.collapsed_categories.contains("text") {
for ch in &text_chs {
let active = s.active_channel.as_deref() == Some(&ch.id);
if items::ch_row_with_menu(ui, "#", &ch.name, active, None, false, Some(net)) {
join(s, net, &ch.id);
}
}
}
}
if !voice_chs.is_empty() {
ui.add_space(4.0);
cat_label(ui, s, "voice");
if !s.collapsed_categories.contains("voice") {
for ch in &voice_chs {
let active = s.active_channel.as_deref() == Some(&ch.id);
let live =
matches!(&s.voice, VoiceUiState::Active { channel } if channel == &ch.id);
let badge = (!ch.members.is_empty()).then(|| ch.members.len().to_string());
if items::ch_row_with_menu(
ui,
"~",
&ch.name,
active,
badge.as_deref(),
live,
Some(net),
) {
join(s, net, &ch.id);
}
if live || ch.name == "lobby" {
for member in &ch.members {
items::member_row(ui, member);
}
}
}
}
}
if s.channels.is_empty() {
ui.add_space(8.0);
ui.label(
egui::RichText::new("waiting for channels…")
.font(egui::FontId::proportional(11.0))
.color(t::TEXT_DISABLED),
);
}
}
fn cat_label(ui: &mut egui::Ui, s: &mut UiState, text: &str) {
ui.add_space(2.0);
let collapsed = s.collapsed_categories.contains(text);
let arrow = if collapsed { "▶" } else { "▼" };
let resp = Frame::NONE
.inner_margin(egui::Margin {
left: 12,
right: 8,
top: 2,
bottom: 2,
})
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(arrow)
.font(egui::FontId::proportional(8.0))
.color(t::TEXT_MUTED),
);
ui.label(
egui::RichText::new(text.to_uppercase())
.font(egui::FontId::proportional(10.0))
.color(t::TEXT_MUTED),
);
});
})
.response
.interact(egui::Sense::click());
if resp.clicked() {
if collapsed {
s.collapsed_categories.remove(text);
} else {
s.collapsed_categories.insert(text.to_string());
}
}
}
fn join(s: &mut UiState, net: &NetHandle, channel_id: &str) {
s.active_channel = Some(channel_id.to_string());
s.server_channel = Some(channel_id.to_string());
let net = net.clone();
let id = channel_id.to_string();
tokio::spawn(async move {
net.send(NetCommand::JoinChannel { channel_id: id }).await;
});
}
fn show_create_channel_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) {
egui::Window::new("Create a Channel")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(300.0);
ui.label(
egui::RichText::new("Channel Name")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_PRIMARY),
);
ui.add_space(8.0);
let resp = ui.add(
egui::TextEdit::singleline(&mut s.create_channel_input)
.font(egui::FontId::proportional(14.0))
.text_color(t::TEXT_PRIMARY)
.hint_text(egui::RichText::new("e.g. dev-talk").color(t::TEXT_DISABLED))
.desired_width(260.0),
);
ui.add_space(8.0);
ui.label(
egui::RichText::new("Type")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_PRIMARY),
);
ui.add_space(4.0);
let kind_text = match s.create_channel_kind.as_str() {
"voice" => "Voice",
_ => "Text",
};
egui::ComboBox::from_id_salt("channel_kind")
.selected_text(kind_text)
.show_ui(ui, |ui| {
ui.selectable_value(&mut s.create_channel_kind, "text".into(), "Text");
ui.selectable_value(&mut s.create_channel_kind, "voice".into(), "Voice");
});
ui.add_space(12.0);
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new(
egui::RichText::new("Cancel")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.create_channel_open = false;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let create_btn = egui::Button::new(
egui::RichText::new("Create")
.font(egui::FontId::proportional(12.0))
.color(egui::Color32::WHITE),
)
.fill(t::ACCENT)
.stroke(egui::Stroke::NONE)
.corner_radius(t::R_SM)
.min_size(egui::vec2(80.0, 28.0));
if ui.add(create_btn).clicked() && !s.create_channel_input.trim().is_empty() {
let name = s.create_channel_input.trim().to_string();
let kind = s.create_channel_kind.clone();
// Send server-side ChannelCreate — gateway will broadcast
// back a ChannelCreated event that updates the sidebar.
let net_clone = net.clone();
let ch_id = name.clone();
let ch_name = name.clone();
let ch_kind = kind.clone();
tokio::spawn(async move {
net_clone
.send(NetCommand::ChannelCreate {
channel_id: ch_id,
channel_name: ch_name,
kind: ch_kind,
})
.await;
});
s.create_channel_open = false;
}
});
});
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
s.create_channel_open = false;
}
});
}

View file

@ -1,82 +0,0 @@
use eframe::egui::{self, FontId, Rect, RichText, Stroke};
use crate::theme as t;
pub const GUILD_ICON_SIZE: f32 = 40.0;
pub fn guild_icon(
ui: &mut egui::Ui,
label: &str,
tooltip: &str,
active: bool,
onclick: impl FnOnce(),
) {
let (bg, stroke) = if active {
(t::ACCENT_10, Stroke::new(1.5, t::ACCENT))
} else {
(t::BG_ELEVATED, Stroke::new(1.0, t::BORDER_DEFAULT))
};
let text_color = if active {
t::ACCENT_SOFT
} else {
t::TEXT_MUTED
};
let btn = egui::Button::new(
RichText::new(label)
.font(FontId::proportional(14.0))
.color(text_color),
)
.fill(bg)
.stroke(stroke)
.corner_radius(t::R_FULL)
.min_size(egui::vec2(GUILD_ICON_SIZE, GUILD_ICON_SIZE));
if ui.add(btn).on_hover_text(tooltip).clicked() {
onclick();
}
}
/// Guild icon that renders a vector-drawn glyph instead of a text label.
pub fn guild_icon_drawn(
ui: &mut egui::Ui,
draw_fn: fn(&egui::Painter, Rect, eframe::egui::Color32),
tooltip: &str,
active: bool,
onclick: impl FnOnce(),
) {
let (bg, stroke) = if active {
(t::ACCENT_10, Stroke::new(1.5, t::ACCENT))
} else {
(t::BG_ELEVATED, Stroke::new(1.0, t::BORDER_DEFAULT))
};
let color = if active {
t::ACCENT_SOFT
} else {
t::TEXT_MUTED
};
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(GUILD_ICON_SIZE, GUILD_ICON_SIZE),
egui::Sense::click(),
);
if ui.is_rect_visible(rect) {
let painter = ui.painter();
painter.rect_filled(rect, t::R_FULL, bg);
painter.rect_stroke(rect, t::R_FULL, stroke, egui::StrokeKind::Middle);
// Inset for the icon.
let pad = 8.0;
let inner = Rect::from_min_max(
(rect.min.x + pad, rect.min.y + pad).into(),
(rect.max.x - pad, rect.max.y - pad).into(),
);
draw_fn(painter, inner, color);
}
if resp.clicked() {
onclick();
}
if !tooltip.is_empty() {
resp.on_hover_text(tooltip);
}
}

View file

@ -1,685 +0,0 @@
use crate::theme::icons as vec_icons;
use crate::{state::UiState, theme as t};
use eframe::egui::{self, FontId, Frame, RichText, Stroke};
use vnox_client::net::{NetCommand, NetHandle};
mod icons;
const BAR_WIDTH: f32 = 56.0;
pub fn show(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) {
egui::SidePanel::left("guild_bar")
.exact_width(BAR_WIDTH)
.resizable(false)
.frame(Frame::NONE.fill(t::BG_STRIP))
.show_separator_line(false)
.show(ctx, |ui| {
let r = ui.max_rect();
ui.painter()
.vline(r.right(), r.y_range(), Stroke::new(1.0, t::BORDER_SUBTLE));
ui.with_layout(
egui::Layout::top_down_justified(egui::Align::Center),
|ui| {
ui.add_space(8.0);
let home_active = s.active_guild_id.is_none();
icons::guild_icon_drawn(ui, vec_icons::home, "Home", home_active, || {
s.active_guild_id = None;
s.active_dm_id = s.dm_conversations.first().map(|c| c.dm_id.clone());
});
ui.add_space(4.0);
ui.painter().hline(
egui::Rangef::new(r.left() + 12.0, r.right() - 12.0),
ui.cursor().top() + 2.0,
Stroke::new(1.0, t::BORDER_SUBTLE),
);
ui.add_space(6.0);
for guild in &s.guilds {
let active = s.active_guild_id.as_deref() == Some(&guild.guild_id);
let initial = guild
.name
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string();
icons::guild_icon(ui, &initial, &guild.name, active, || {
s.active_guild_id = Some(guild.guild_id.clone());
let net = net.clone();
let gid = guild.guild_id.clone();
tokio::spawn(async move {
net.send(vnox_client::net::NetCommand::GuildList).await;
let _ = gid;
});
});
ui.add_space(2.0);
}
ui.add_space(4.0);
if t::icon_btn_drawn(
ui,
vec_icons::plus,
"create a guild",
None,
icons::GUILD_ICON_SIZE,
)
.clicked()
{
s.create_guild_open = true;
s.create_guild_input.clear();
}
ui.add_space(4.0);
if t::icon_btn_drawn(
ui,
vec_icons::link,
"join with invite code",
None,
icons::GUILD_ICON_SIZE,
)
.clicked()
{
s.invite_accept_open = true;
s.invite_accept_code.clear();
}
ui.add_space(4.0);
let has_guild = s.active_guild_id.is_some();
let audit_resp = t::icon_btn_drawn(
ui,
vec_icons::clipboard,
"view audit log (admins only)",
None,
icons::GUILD_ICON_SIZE,
);
if has_guild
&& audit_resp.clicked()
&& let Some(gid) = s.active_guild_id.clone()
{
s.audit_log_open = true;
s.audit_log_guild_id = Some(gid.clone());
s.audit_log_entries.clear();
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::GuildAuditLogFetch {
guild_id: gid,
limit: 50,
})
.await;
});
}
ui.add_space(4.0);
let members_resp = t::icon_btn_drawn(
ui,
vec_icons::people,
"guild members",
None,
icons::GUILD_ICON_SIZE,
);
let has_guild = s.active_guild_id.is_some();
if has_guild
&& members_resp.clicked()
&& let Some(gid) = s.active_guild_id.clone()
{
s.guild_members_open = true;
s.guild_member_list_guild_id = Some(gid.clone());
s.guild_member_list.clear();
s.guild_role_list.clear();
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::GuildMemberListFetch {
guild_id: gid.clone(),
})
.await;
net.send(NetCommand::GuildRoleListFetch { guild_id: gid })
.await;
});
}
},
);
if s.create_guild_open {
show_create_guild_popup(ctx, s, net);
}
if s.invite_accept_open {
show_invite_accept_popup(ctx, s, net);
}
if s.audit_log_open {
show_audit_log_popup(ctx, s, net);
}
if s.guild_members_open {
show_members_popup(ctx, s, net);
}
});
}
fn show_create_guild_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) {
egui::Window::new("Create a Guild")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(280.0);
ui.label(
RichText::new("Guild Name")
.font(FontId::proportional(12.0))
.color(t::TEXT_PRIMARY),
);
ui.add_space(8.0);
let resp = ui.add(
egui::TextEdit::singleline(&mut s.create_guild_input)
.font(FontId::proportional(14.0))
.text_color(t::TEXT_PRIMARY)
.hint_text(RichText::new("e.g. My Awesome Guild").color(t::TEXT_DISABLED))
.desired_width(240.0),
);
ui.add_space(12.0);
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new(
RichText::new("Cancel")
.font(FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.create_guild_open = false;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let create_btn = egui::Button::new(
RichText::new("Create")
.font(FontId::proportional(12.0))
.color(egui::Color32::WHITE),
)
.fill(t::ACCENT)
.stroke(Stroke::NONE)
.corner_radius(t::R_SM)
.min_size(egui::vec2(80.0, 28.0));
if ui.add(create_btn).clicked() && !s.create_guild_input.trim().is_empty() {
let net = net.clone();
let name = s.create_guild_input.trim().to_string();
tokio::spawn(async move {
net.send(vnox_client::net::NetCommand::GuildCreate { name })
.await;
});
s.create_guild_open = false;
}
});
});
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
s.create_guild_open = false;
}
});
}
fn show_invite_accept_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) {
egui::Window::new("Join a Guild")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(280.0);
ui.label(
RichText::new("Invite Code")
.font(FontId::proportional(12.0))
.color(t::TEXT_PRIMARY),
);
ui.add_space(8.0);
let resp = ui.add(
egui::TextEdit::singleline(&mut s.invite_accept_code)
.font(FontId::monospace(14.0))
.text_color(t::TEXT_PRIMARY)
.hint_text(RichText::new("e.g. a1b2c3d4").color(t::TEXT_DISABLED))
.desired_width(240.0),
);
ui.add_space(12.0);
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new(
RichText::new("Cancel")
.font(FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.invite_accept_open = false;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let join_btn = egui::Button::new(
RichText::new("Join")
.font(FontId::proportional(12.0))
.color(egui::Color32::WHITE),
)
.fill(t::ACCENT)
.stroke(Stroke::NONE)
.corner_radius(t::R_SM)
.min_size(egui::vec2(80.0, 28.0));
if ui.add(join_btn).clicked() && !s.invite_accept_code.trim().is_empty() {
let net = net.clone();
let code = s.invite_accept_code.trim().to_string();
tokio::spawn(async move {
net.send(NetCommand::InviteAccept { code }).await;
});
s.invite_accept_open = false;
}
});
});
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
s.invite_accept_open = false;
}
});
}
fn show_audit_log_popup(ctx: &egui::Context, s: &mut UiState, _net: &NetHandle) {
egui::Window::new("Audit Log")
.collapsible(false)
.resizable(true)
.default_size([520.0, 420.0])
.min_size([400.0, 300.0])
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(500.0);
ui.horizontal(|ui| {
ui.label(
RichText::new("Last 50 admin actions for this guild")
.font(FontId::proportional(11.0))
.color(t::TEXT_MUTED),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if t::icon_btn_drawn(ui, vec_icons::close, "close", None, 18.0).clicked() {
s.audit_log_open = false;
}
});
});
ui.add_space(6.0);
ui.separator();
ui.add_space(4.0);
if s.audit_log_entries.is_empty() {
ui.vertical_centered(|ui| {
ui.add_space(40.0);
ui.label(
RichText::new("no audit log entries")
.font(FontId::proportional(12.0))
.color(t::TEXT_DISABLED),
);
ui.add_space(4.0);
ui.label(
RichText::new("(loading or empty — admin actions like kick, role change, invite create will appear here)")
.font(FontId::proportional(10.0))
.color(t::TEXT_DISABLED),
);
});
return;
}
egui::ScrollArea::vertical()
.auto_shrink([false, true])
.show(ui, |ui| {
for entry in s.audit_log_entries.clone() {
Frame::NONE
.fill(t::BG_SURFACE)
.corner_radius(t::R_SM)
.stroke(egui::Stroke::new(1.0, t::BORDER_SUBTLE))
.inner_margin(egui::Margin::symmetric(10, 6))
.show(ui, |ui| {
ui.horizontal(|ui| {
let (icon, color) = action_icon(&entry.action);
ui.label(
RichText::new(icon)
.font(FontId::proportional(14.0))
.color(color),
);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(&entry.action)
.font(FontId::proportional(12.0))
.color(t::TEXT_PRIMARY)
.strong(),
);
ui.label(
RichText::new(format!("· by {}…", &entry.actor_id[..8.min(entry.actor_id.len())]))
.font(FontId::monospace(10.0))
.color(t::TEXT_DISABLED),
);
});
if let Some(t_id) = &entry.target_id {
ui.label(
RichText::new(format!("target: {}…", &t_id[..8.min(t_id.len())]))
.font(FontId::monospace(10.0))
.color(t::TEXT_MUTED),
);
}
if let Some(reason) = &entry.reason {
ui.label(
RichText::new(format!("reason: {reason}"))
.font(FontId::proportional(11.0))
.color(t::TEXT_MUTED)
.italics(),
);
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(fmt_audit_ts(entry.created_at))
.font(FontId::monospace(10.0))
.color(t::TEXT_DISABLED),
);
});
});
});
ui.add_space(2.0);
}
});
});
}
fn action_icon(action: &str) -> (&'static str, egui::Color32) {
match action {
"guild_create" | "GUILD_CREATE" => ("[+]", t::SUCCESS),
"guild_delete" | "GUILD_DELETE" => ("[-]", t::ERROR),
"member_kick" | "MEMBER_KICK" => ("[kick]", t::ERROR),
"member_leave" | "MEMBER_LEAVE" => ("[leave]", t::WARNING),
"role_create" | "ROLE_CREATE" => ("[role+]", t::INFO),
"role_delete" | "ROLE_DELETE" => ("[role-]", t::ERROR),
"role_assign" | "ROLE_ASSIGN" => ("[role=]", t::INFO),
"role_unassign" | "ROLE_UNASSIGN" => ("[role!=]", t::WARNING),
"invite_create" | "INVITE_CREATE" => ("[invite+]", t::SUCCESS),
"invite_delete" | "INVITE_DELETE" => ("[invite-]", t::ERROR),
"invite_accept" | "INVITE_ACCEPT" => ("[invite=]", t::SUCCESS),
_ => ("[*]", t::TEXT_MUTED),
}
}
fn fmt_audit_ts(ts: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let diff = (now - ts / 1000).max(0);
if diff < 60 {
format!("{diff}s ago")
} else if diff < 3600 {
format!("{}m ago", diff / 60)
} else if diff < 86400 {
format!("{}h ago", diff / 3600)
} else {
format!("{}d ago", diff / 86400)
}
}
fn show_members_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) {
egui::Window::new("Guild Members")
.collapsible(false)
.resizable(true)
.default_size([520.0, 460.0])
.min_size([400.0, 320.0])
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(500.0);
ui.horizontal(|ui| {
let count = s.guild_member_list.len();
ui.label(
RichText::new(format!("{count} members"))
.font(FontId::proportional(11.0))
.color(t::TEXT_MUTED),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if t::icon_btn_drawn(ui, vec_icons::refresh, "refresh", None, 18.0).clicked()
&& let Some(gid) = s.guild_member_list_guild_id.clone()
{
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::GuildMemberListFetch {
guild_id: gid.clone(),
})
.await;
net.send(NetCommand::GuildRoleListFetch { guild_id: gid })
.await;
});
}
if t::icon_btn_drawn(ui, vec_icons::close, "close", None, 18.0).clicked() {
s.guild_members_open = false;
}
});
});
ui.add_space(6.0);
ui.separator();
ui.add_space(4.0);
if s.guild_member_list.is_empty() {
ui.vertical_centered(|ui| {
ui.add_space(40.0);
ui.label(
RichText::new("no members")
.font(FontId::proportional(12.0))
.color(t::TEXT_DISABLED),
);
});
return;
}
// Determine if current user is owner (kick allowed).
// For simplicity, we allow kick UI for everyone; gateway enforces permission.
let roles_clone = s.guild_role_list.clone();
let guild_id = s.guild_member_list_guild_id.clone().unwrap_or_default();
egui::ScrollArea::vertical()
.auto_shrink([false, true])
.show(ui, |ui| {
for member in s.guild_member_list.clone() {
let role_color =
parse_hex_color(&member.role_color).unwrap_or(t::TEXT_PRIMARY);
Frame::NONE
.fill(t::BG_SURFACE)
.corner_radius(t::R_SM)
.stroke(egui::Stroke::new(1.0, t::BORDER_SUBTLE))
.inner_margin(egui::Margin::symmetric(10, 6))
.show(ui, |ui| {
ui.horizontal(|ui| {
// Avatar with role-colored ring
let init = member
.nickname
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string();
let (r, _) = ui.allocate_exact_size(
egui::vec2(28.0, 28.0),
egui::Sense::hover(),
);
ui.painter().circle_filled(r.center(), 14.0, role_color);
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
&init,
egui::FontId::proportional(12.0),
egui::Color32::from_rgb(15, 15, 15),
);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(&member.nickname)
.font(FontId::proportional(13.0))
.color(t::TEXT_PRIMARY)
.strong(),
);
if member.is_owner {
let (cr, _) = ui.allocate_exact_size(
egui::vec2(14.0, 14.0),
egui::Sense::hover(),
);
vec_icons::crown(ui.painter(), cr, t::WARNING);
}
});
ui.horizontal(|ui| {
let (dot_r, _) = ui.allocate_exact_size(
egui::vec2(8.0, 8.0),
egui::Sense::hover(),
);
ui.painter().circle_filled(
dot_r.center(),
4.0,
role_color,
);
ui.label(
RichText::new(&member.role_name)
.font(FontId::proportional(10.0))
.color(t::TEXT_MUTED),
);
ui.label(
RichText::new(format!(
"· {}…",
&member.user_id[..8.min(member.user_id.len())]
))
.font(FontId::monospace(10.0))
.color(t::TEXT_DISABLED),
);
});
});
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
// Role-assign combo (only shows if user has roles defined).
if !roles_clone.is_empty() && !member.is_owner {
let combo_text = "+ role".to_string();
egui::ComboBox::from_id_salt(format!(
"role_combo_{}",
member.user_id
))
.selected_text(combo_text)
.width(120.0)
.show_ui(ui, |ui| {
for role in &roles_clone {
if ui
.add(
egui::Button::new(
RichText::new(&role.name)
.font(FontId::proportional(
11.0,
))
.color(
parse_hex_color(
&role.color,
)
.unwrap_or(
t::TEXT_PRIMARY,
),
),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
let net = net.clone();
let gid = guild_id.clone();
let uid = member.user_id.clone();
let rid = role.id.clone();
tokio::spawn(async move {
net.send(
NetCommand::GuildRoleAssign {
guild_id: gid,
user_id: uid,
role_id: rid,
},
)
.await;
});
}
}
});
}
// Kick button (hidden for owner).
if !member.is_owner
&& t::icon_btn_drawn(
ui,
vec_icons::boot,
"kick (admins only)",
Some(t::ERROR),
18.0,
)
.clicked()
{
let net_kick = net.clone();
let net_refresh = net.clone();
let gid = guild_id.clone();
let uid = member.user_id.clone();
tokio::spawn(async move {
net_kick
.send(NetCommand::GuildMemberKick {
guild_id: gid.clone(),
user_id: uid,
})
.await;
tokio::time::sleep(
std::time::Duration::from_millis(200),
)
.await;
net_refresh
.send(NetCommand::GuildMemberListFetch {
guild_id: gid,
})
.await;
});
}
},
);
});
});
ui.add_space(2.0);
}
});
});
}
fn parse_hex_color(hex: &str) -> Option<egui::Color32> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(egui::Color32::from_rgb(r, g, b))
}

View file

@ -1,94 +0,0 @@
//! Unified left sidebar (design from aiupdate).
mod channel_list;
mod guild_bar;
mod sections;
pub use guild_bar::show as guild_bar;
use eframe::egui::{self, Context, Frame, SidePanel};
use vnox_client::{identity::Identity, net::NetHandle};
use crate::{
connect,
state::{ConnState, UiState},
theme as t,
};
pub fn show(ctx: &Context, s: &mut UiState, identity: &Identity, net: &NetHandle) {
SidePanel::left("sidebar")
.exact_width(220.0)
.resizable(false)
.frame(Frame::NONE.fill(t::BG_SURFACE))
.show_separator_line(false)
.show(ctx, |ui| {
let r = ui.max_rect();
ui.painter().vline(
r.right(),
r.y_range(),
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
sections::header::show(ui, s);
bookmark_row(ui, s, net);
let userbar_h = 52.0;
let avail = (ui.available_height() - userbar_h).max(0.0);
egui::ScrollArea::vertical()
.max_height(avail)
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(6.0);
channel_list::show(ui, s, net);
ui.add_space(4.0);
sections::dm::show(ui, s, net);
ui.add_space(6.0);
});
sections::userbar::show(ui, s, identity, net);
});
});
}
fn bookmark_row(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
let busy = matches!(
s.conn,
ConnState::Connecting | ConnState::Reconnecting { .. }
);
if s.bookmarks.is_empty() {
return;
}
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0);
ui.add_space(8.0);
for (i, bm) in s.bookmarks.clone().into_iter().enumerate() {
let active = s.selected_bookmark == i;
let label = bm.label.chars().take(8).collect::<String>();
let btn = egui::Button::new(
egui::RichText::new(label)
.font(egui::FontId::proportional(10.0))
.color(if active {
t::ACCENT_SOFT
} else {
t::TEXT_MUTED
}),
)
.fill(if active { t::ACCENT_10 } else { t::BG_ELEVATED })
.stroke(egui::Stroke::new(
1.0,
if active {
t::BORDER_ACCENT
} else {
t::BORDER_DEFAULT
},
))
.corner_radius(t::R_SM);
if ui.add(btn).on_hover_text(&bm.address).clicked() && !busy {
s.selected_bookmark = i;
connect::connect_to(s, net, &bm.address);
}
}
});
ui.add_space(4.0);
}

View file

@ -1,170 +0,0 @@
use eframe::egui::{self, Frame};
use vnox_client::net::{NetCommand, NetHandle};
use crate::state::{ConnState, UiState};
use crate::theme as t;
pub fn show(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
if !matches!(s.conn, ConnState::Connected { .. }) {
return;
}
Frame::NONE
.inner_margin(egui::Margin {
left: 12,
right: 8,
top: 2,
bottom: 2,
})
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("DIRECT MESSAGES")
.font(egui::FontId::proportional(10.0))
.color(t::TEXT_MUTED),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(
egui::RichText::new("+")
.font(egui::FontId::proportional(12.0))
.color(t::ACCENT),
)
.frame(false)
.min_size(egui::vec2(16.0, 16.0)),
)
.on_hover_text("new DM")
.clicked()
{
s.new_dm_input = Some(String::new());
}
});
});
});
new_dm_input(ui, s, net);
for conv in s.dm_conversations.clone() {
let active = s.active_dm_id.as_deref() == Some(&conv.dm_id);
let bg = if active {
t::ACCENT_10
} else {
egui::Color32::TRANSPARENT
};
let stroke = if active {
egui::Stroke::new(1.0, t::BORDER_ACCENT)
} else {
egui::Stroke::NONE
};
let text_c = if active {
t::ACCENT_SOFT
} else {
t::TEXT_MUTED
};
let resp = Frame::NONE
.fill(bg)
.stroke(stroke)
.corner_radius(t::R_SM)
.inner_margin(egui::Margin::symmetric(8, 3))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("@").color(text_c).size(13.0));
ui.label(
egui::RichText::new(&conv.other_nickname)
.color(text_c)
.size(13.0),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if conv.unread_count > 0 {
let text = if conv.unread_count > 99 {
"99+".to_string()
} else {
conv.unread_count.to_string()
};
let (r, _) = ui
.allocate_exact_size(egui::vec2(18.0, 14.0), egui::Sense::hover());
ui.painter()
.rect_filled(r, egui::CornerRadius::same(4), t::ACCENT);
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
&text,
egui::FontId::monospace(10.0),
egui::Color32::WHITE,
);
}
});
});
})
.response
.interact(egui::Sense::click());
if resp.clicked() {
let clicked_dm_id = conv.dm_id.clone();
s.active_dm_id = Some(clicked_dm_id.clone());
if let Some(c) = s
.dm_conversations
.iter_mut()
.find(|c| c.dm_id == conv.dm_id)
{
c.unread_count = 0;
}
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::DmReadAck {
dm_id: clicked_dm_id,
})
.await;
});
}
}
}
pub fn new_dm_input(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
let mut input_buf = s.new_dm_input.clone().unwrap_or_default();
let mut should_start = false;
if s.new_dm_input.is_some() {
Frame::NONE
.inner_margin(egui::Margin::symmetric(12, 2))
.show(ui, |ui| {
ui.horizontal(|ui| {
let resp = ui.add_sized(
egui::vec2(120.0, 20.0),
egui::TextEdit::singleline(&mut input_buf)
.font(egui::FontId::proportional(11.0))
.text_color(t::TEXT_PRIMARY)
.hint_text(egui::RichText::new("user id").color(t::TEXT_DISABLED))
.frame(true),
);
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))
|| ui
.add(
egui::Button::new(
egui::RichText::new("Start")
.font(egui::FontId::proportional(10.0))
.color(t::ACCENT),
)
.fill(t::ACCENT_10)
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
.corner_radius(t::R_SM)
.min_size(egui::vec2(40.0, 18.0)),
)
.clicked()
&& !input_buf.trim().is_empty()
{
should_start = true;
}
});
});
}
if should_start {
let target = input_buf.trim().to_string();
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::DmStart {
target_user_id: target,
})
.await;
});
s.new_dm_input = None;
}
}

View file

@ -1,95 +0,0 @@
use eframe::egui::{self, Frame};
use crate::state::{ConnState, UiState};
use crate::theme as t;
pub fn show(ui: &mut egui::Ui, s: &UiState) {
let title = if let Some(gid) = &s.active_guild_id
&& let Some(g) = s.guilds.iter().find(|g| &g.guild_id == gid)
{
g.name.clone()
} else {
match &s.conn {
ConnState::Connected { node_name, .. } => node_name.clone(),
ConnState::Reconnecting { attempt } => format!("reconnect #{attempt}"),
ConnState::Connecting => "connecting…".into(),
ConnState::Disconnected => "not connected".into(),
}
};
let is_guild = s.active_guild_id.is_some();
Frame::NONE
.fill(t::BG_SURFACE)
.inner_margin(egui::Margin::symmetric(12, 0))
.show(ui, |ui| {
ui.set_height(38.0);
ui.horizontal_centered(|ui| {
if is_guild {
let (r, _) =
ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover());
ui.painter().rect_filled(r, t::R_FULL, t::ACCENT);
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
title
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string(),
egui::FontId::monospace(11.0),
egui::Color32::WHITE,
);
} else {
let (r, _) =
ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover());
ui.painter().rect_filled(r, t::R_SM, t::ACCENT_20);
ui.painter().rect_stroke(
r,
t::R_SM,
egui::Stroke::new(1.0, t::BORDER_ACCENT),
egui::StrokeKind::Inside,
);
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
"V",
egui::FontId::monospace(11.0),
t::TEXT_PRIMARY,
);
}
ui.add_space(8.0);
ui.label(
egui::RichText::new(title)
.font(egui::FontId::proportional(13.0))
.color(t::TEXT_PRIMARY)
.strong(),
);
if s.private_mode {
ui.add_space(6.0);
Frame::NONE
.fill(t::WARNING)
.corner_radius(t::R_SM)
.inner_margin(egui::Margin::symmetric(4, 1))
.show(ui, |ui| {
ui.label(
egui::RichText::new("PRIVATE")
.font(egui::FontId::monospace(8.0))
.color(egui::Color32::from_rgb(15, 15, 15))
.strong(),
);
});
}
});
});
let sep = ui.max_rect();
ui.painter().hline(
sep.x_range(),
sep.top() + 38.0,
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
}

View file

@ -1,3 +0,0 @@
pub mod dm;
pub mod header;
pub mod userbar;

View file

@ -1,281 +0,0 @@
use eframe::egui::{self, FontId, Frame, RichText};
use vnox_client::{
identity::Identity,
net::{NetCommand, NetHandle},
};
use crate::state::UiState;
use crate::theme as t;
use crate::theme::icons;
pub fn show(ui: &mut egui::Ui, s: &mut UiState, identity: &Identity, net: &NetHandle) {
let r = ui.available_rect_before_wrap();
ui.painter().hline(
r.x_range(),
r.top(),
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
Frame::NONE
.fill(t::BG_ELEVATED)
.inner_margin(egui::Margin::symmetric(10, 8))
.show(ui, |ui| {
ui.horizontal(|ui| {
let init = identity
.nickname
.chars()
.next()
.unwrap_or('U')
.to_uppercase()
.to_string();
t::avatar(
ui,
&init,
avatar_color_for(&identity.nickname, s.local_speaking),
30.0,
);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(&identity.nickname)
.font(egui::FontId::proportional(12.5))
.color(if s.local_speaking {
t::SUCCESS
} else {
t::TEXT_PRIMARY
})
.strong(),
);
if s.local_speaking {
let (r, _) = ui
.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
icons::speaker(ui.painter(), r, t::SUCCESS);
}
});
// Status text — shows voice state, custom status, or activity.
let status_text = if let Some(ch) = s.active_voice_channel() {
if s.remote_speaking {
format!("in voice · {} · talking", ch.name)
} else {
format!("in voice · {}", ch.name)
}
} else if let Some(ref custom) = s.custom_status {
custom.clone()
} else if let (Some(_kind), Some(text)) = (&s.activity_type, &s.activity_text) {
text.clone()
} else if s.overlay_latency {
match s.rtt_ms {
Some(ms) => format!("online · {ms}ms"),
None => "online".into(),
}
} else {
"online".into()
};
let status_label = ui.add(
egui::Button::new(
RichText::new(status_text)
.font(FontId::proportional(10.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
);
status_label.context_menu(|ui| {
status_picker(ui, s, net);
ui.separator();
custom_status_editor(ui, s, net);
ui.separator();
activity_editor(ui, s, net);
});
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 1.0;
if t::icon_btn_drawn(ui, icons::gear, "settings", None, 22.0).clicked() {
s.settings_open = true;
}
let deaf_c = if s.deafen { Some(t::ERROR) } else { None };
let deaf_icon = if s.deafen {
icons::headphones_off
} else {
icons::headphones
};
if t::icon_btn_drawn(ui, deaf_icon, "deafen", deaf_c, 22.0).clicked() {
s.deafen = !s.deafen;
}
let mic_icon = if s.mic_enabled {
icons::microphone
} else {
icons::microphone_off
};
let mic_c = if !s.mic_enabled {
Some(t::ERROR)
} else if s.local_speaking {
Some(t::SUCCESS)
} else {
None
};
if t::icon_btn_drawn(ui, mic_icon, "microphone", mic_c, 22.0).clicked() {
s.mic_enabled = !s.mic_enabled;
}
});
});
});
}
fn status_picker(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
ui.label(
RichText::new("Status")
.font(FontId::proportional(10.0))
.color(t::TEXT_MUTED)
.strong(),
);
let options = [
("online", "Online"),
("idle", "Idle"),
("dnd", "Do Not Disturb"),
("invisible", "Invisible"),
];
for (key, label) in options {
let selected = s.presence_status.as_deref() == Some(key);
let btn = egui::Button::new(RichText::new(label).font(FontId::proportional(12.0)))
.fill(if selected {
t::ACCENT_10
} else {
egui::Color32::TRANSPARENT
})
.frame(false);
if ui.add(btn).clicked() {
s.presence_status = Some(key.to_string());
send_presence(s, net);
ui.close();
}
}
}
fn custom_status_editor(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
ui.label(
RichText::new("Custom status")
.font(FontId::proportional(10.0))
.color(t::TEXT_MUTED)
.strong(),
);
let mut buf = s.custom_status.clone().unwrap_or_default();
let resp = ui.add(
egui::TextEdit::singleline(&mut buf)
.font(FontId::proportional(12.0))
.hint_text("what's on your mind?")
.desired_width(180.0)
.text_color(t::TEXT_PRIMARY),
);
if resp.changed() {
s.custom_status = if buf.trim().is_empty() {
None
} else {
Some(buf)
};
}
if resp.lost_focus() {
send_presence(s, net);
}
if ui
.add(
egui::Button::new(
RichText::new("Clear")
.font(FontId::proportional(11.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.custom_status = None;
send_presence(s, net);
ui.close();
}
}
fn activity_editor(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
ui.label(
RichText::new("Activity")
.font(FontId::proportional(10.0))
.color(t::TEXT_MUTED)
.strong(),
);
let kinds = [
("playing", "Playing"),
("listening", "Listening"),
("watching", "Watching"),
("streaming", "Streaming"),
];
let mut current_kind = s.activity_type.clone();
let combo = egui::ComboBox::from_id_salt("activity_kind")
.selected_text(current_kind.clone().unwrap_or_else(|| "— none —".into()))
.width(160.0);
combo.show_ui(ui, |ui| {
for (k, label) in kinds {
ui.selectable_value(&mut current_kind, Some(k.to_string()), label);
}
ui.selectable_value(&mut current_kind, None, "— none —");
});
let mut buf = s.activity_text.clone().unwrap_or_default();
let resp = ui.add(
egui::TextEdit::singleline(&mut buf)
.font(FontId::proportional(12.0))
.hint_text("doing what?")
.desired_width(180.0)
.text_color(t::TEXT_PRIMARY),
);
let mut changed = false;
if resp.changed() {
s.activity_text = if buf.trim().is_empty() {
None
} else {
Some(buf)
};
changed = true;
}
if current_kind != s.activity_type {
s.activity_type = current_kind;
changed = true;
}
if changed && (resp.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter))) {
send_presence(s, net);
}
}
fn send_presence(s: &UiState, net: &NetHandle) {
let net = net.clone();
let status = s.presence_status.clone().unwrap_or_else(|| "online".into());
let custom = s.custom_status.clone();
let activity = s.activity_type.clone();
let activity_text = s.activity_text.clone();
tokio::spawn(async move {
net.send(NetCommand::PresenceUpdate {
status,
custom_status: custom,
activity,
activity_text,
})
.await;
});
}
fn avatar_color_for(nick: &str, speaking: bool) -> egui::Color32 {
if speaking {
return t::SUCCESS;
}
let colors = [
t::ACCENT_10,
egui::Color32::from_rgb(50, 70, 100),
egui::Color32::from_rgb(60, 80, 50),
egui::Color32::from_rgb(80, 60, 80),
];
colors[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % colors.len()]
}

123
src/ui/slint/chat.slint Normal file
View file

@ -0,0 +1,123 @@
import { Theme } from "theme.slint";
export struct MessageItem {
message_id: string,
sender_id: string,
sender_name: string,
content: string,
timestamp: string,
is_own: bool,
edited: bool,
reply_to: string,
}
export struct DmConversationItem {
dm_id: string,
other_user_id: string,
other_nickname: string,
e2ee_enabled: bool,
messages: [MessageItem],
}
export struct MemberItem {
user_id: string,
nickname: string,
status: string,
}
export component ChatPanel {
in-out property <string> active-channel-id;
in-out property <string> active-channel-name;
in-out property <[MessageItem]> messages;
in-out property <[MemberItem]> members;
in-out property <string> input-text;
in-out property <bool> is-dm;
in-out property <bool> e2ee-enabled;
in-out property <string> dm-partner-name;
callback send-message(string, string);
callback load-more(string);
VerticalLayout {
padding: 0;
spacing: 0;
Rectangle {
height: 48px;
background: Theme.bg-surface;
HorizontalLayout {
padding: 8px;
spacing: 4px;
Text {
text: e2ee-enabled ? "🔒 " : "";
color: Theme.accent;
}
Text {
text: is-dm ? dm-partner-name : "#" + active-channel-name;
color: Theme.text-primary;
font-size: 16px;
}
}
}
Flickable {
VerticalLayout {
for msg in messages: Rectangle {
height: 60px;
VerticalLayout {
padding: 8px;
spacing: 4px;
HorizontalLayout {
spacing: 8px;
Text {
text: msg.sender_name;
color: Theme.accent;
font-weight: 700;
}
Text {
text: msg.timestamp;
color: Theme.text-muted;
font-size: 11px;
}
}
Text {
text: msg.content;
wrap: word-wrap;
}
}
}
}
}
Rectangle {
height: 48px;
background: Theme.bg-surface;
HorizontalLayout {
padding: 8px;
spacing: 8px;
TextInput {
text <=> input-text;
horizontal-stretch: 1;
}
Rectangle {
width: 64px;
height: 32px;
background: Theme.accent;
border-radius: Theme.radius;
TouchArea {
clicked => {
send-message(active-channel-id, input-text);
input-text = "";
}
}
Text {
text: "Send";
color: Theme.text-primary;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
}

101
src/ui/slint/connect.slint Normal file
View file

@ -0,0 +1,101 @@
import { Theme } from "theme.slint";
import { Button, ListView, LineEdit } from "std-widgets.slint";
export struct BookmarkItem {
name: string,
address: string,
}
export component ConnectScreen {
in-out property <[BookmarkItem]> bookmarks;
in-out property <string> address-input;
in-out property <string> status-text;
in-out property <bool> connecting;
in-out property <bool> auto-reconnect;
in-out property <string> node-name;
callback connect(string);
callback disconnect();
callback remove-bookmark(string);
callback add-bookmark(string);
VerticalLayout {
alignment: center;
spacing: 16px;
Text { text: "VNOX"; color: Theme.accent; font-size: 36px; font-weight: 700; }
Text { text: "Self-hosted voice & chat"; color: Theme.text-secondary; font-size: 14px; }
HorizontalLayout {
LineEdit {
text <=> address-input;
placeholder-text: "server:7600";
width: 300px;
}
Rectangle {
width: 100px;
height: 30px;
background: Theme.accent;
border-radius: 6px;
TouchArea {
enabled: !connecting;
clicked => { connect(address-input); }
}
Text {
text: connecting ? "Connecting..." : "Connect";
color: Theme.text-primary;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
Text { text: status-text; color: Theme.text-muted; }
HorizontalLayout {
Rectangle {
width: 16px;
height: 16px;
background: auto-reconnect ? Theme.accent : Theme.bg-elevated;
border-radius: 3px;
border-width: 1px;
border-color: Theme.text-muted;
TouchArea {
clicked => { auto-reconnect = !auto-reconnect; }
}
}
Text { text: "Auto-reconnect"; color: Theme.text-secondary; }
}
Text { text: "Saved Servers"; color: Theme.text-primary; font-size: 16px; }
ListView {
for bm in bookmarks: Rectangle {
height: 40px;
HorizontalLayout {
padding: 8px;
Text { text: bm.name; color: Theme.text-primary; }
Text { text: bm.address; color: Theme.text-muted; }
HorizontalLayout {
Button { text: "Connect"; clicked => { connect(bm.address); } }
Button { text: "✕"; clicked => { remove-bookmark(bm.address); } }
}
}
}
}
HorizontalLayout {
Button { text: "+ Add Server"; clicked => { add-bookmark(address-input); } }
}
Text { text: node-name; color: Theme.success; }
Rectangle {
width: 100px;
height: 30px;
background: Theme.danger;
border-radius: 6px;
visible: node-name != "";
TouchArea { clicked => { disconnect(); } }
Text { text: "Disconnect"; color: Theme.text-primary; horizontal-alignment: center; vertical-alignment: center; }
}
}
}

123
src/ui/slint/friends.slint Normal file
View file

@ -0,0 +1,123 @@
import { Theme } from "theme.slint";
export struct FriendItem {
user_id: string,
nickname: string,
status: string,
}
export component FriendsPanel {
in-out property <[FriendItem]> friends;
in-out property <[FriendItem]> pending-requests;
in-out property <[string]> blocked-users;
in-out property <string> active-tab;
in-out property <string> add-friend-input;
callback add-friend(string);
callback accept-friend(string);
callback decline-friend(string);
callback remove-friend(string);
callback start-dm(string);
VerticalLayout {
padding: 16px;
spacing: 8px;
Text {
text: "Friends";
color: Theme.text-primary;
font-size: 18px;
}
HorizontalLayout {
spacing: 4px;
Rectangle {
height: 28px;
background: Theme.bg-interactive;
border-radius: Theme.radius;
TouchArea {
clicked => { active-tab = "online"; }
}
Text { text: "Online"; color: Theme.text-primary; horizontal-alignment: center; vertical-alignment: center; }
}
Rectangle {
height: 28px;
background: Theme.bg-interactive;
border-radius: Theme.radius;
TouchArea {
clicked => { active-tab = "all"; }
}
Text { text: "All"; color: Theme.text-primary; horizontal-alignment: center; vertical-alignment: center; }
}
Rectangle {
height: 28px;
background: Theme.bg-interactive;
border-radius: Theme.radius;
TouchArea {
clicked => { active-tab = "pending"; }
}
Text { text: "Pending"; color: Theme.text-primary; horizontal-alignment: center; vertical-alignment: center; }
}
Rectangle {
height: 28px;
background: Theme.bg-interactive;
border-radius: Theme.radius;
TouchArea {
clicked => { active-tab = "blocked"; }
}
Text { text: "Blocked"; color: Theme.text-primary; horizontal-alignment: center; vertical-alignment: center; }
}
}
HorizontalLayout {
spacing: 8px;
TextInput {
text <=> add-friend-input;
horizontal-stretch: 1;
}
Rectangle {
width: 32px;
height: 32px;
background: Theme.accent;
border-radius: Theme.radius;
TouchArea {
clicked => {
add-friend(add-friend-input);
add-friend-input = "";
}
}
Text { text: "+"; color: Theme.text-primary; horizontal-alignment: center; vertical-alignment: center; }
}
}
Flickable {
VerticalLayout {
for f in friends: Rectangle {
height: 48px;
HorizontalLayout {
padding: 8px;
spacing: 8px;
Text {
text: f.nickname;
color: Theme.text-primary;
}
Text {
text: f.status;
color: Theme.text-muted;
}
Rectangle {
width: 40px;
height: 28px;
background: Theme.accent;
border-radius: Theme.radius;
TouchArea {
clicked => { start-dm(f.user_id); }
}
Text { text: "DM"; color: Theme.text-primary; horizontal-alignment: center; vertical-alignment: center; }
}
}
}
}
}
}
}

147
src/ui/slint/main.slint Normal file
View file

@ -0,0 +1,147 @@
import { Theme } from "theme.slint";
import { Sidebar, ChannelItem, GuildItem } from "sidebar.slint";
import { ChatPanel, MessageItem, MemberItem } from "chat.slint";
import { FriendsPanel, FriendItem } from "friends.slint";
import { VoicePanel, VoiceMemberItem } from "voice.slint";
import { BookmarkItem, ConnectScreen } from "connect.slint";
export component MainWindow inherits Window {
in-out property <[ChannelItem]> channels;
in-out property <[GuildItem]> guilds;
in-out property <[string]> dm-conversations;
in-out property <string> username;
in-out property <string> user-status;
in-out property <string> active-channel-id;
in-out property <int> active-guild-idx;
in-out property <string> active-channel-name;
in-out property <[MessageItem]> messages;
in-out property <[MemberItem]> members;
in-out property <string> input-text;
in-out property <bool> is-dm;
in-out property <bool> e2ee-enabled;
in-out property <string> dm-partner-name;
in-out property <bool> show-chat;
in-out property <bool> show-friends;
in-out property <bool> show-voice;
in-out property <[FriendItem]> friends;
in-out property <[FriendItem]> pending-requests;
in-out property <[string]> blocked-users;
in-out property <string> friends-active-tab;
in-out property <string> add-friend-input;
in-out property <[VoiceMemberItem]> voice-members;
in-out property <bool> mic-enabled;
in-out property <bool> deafened;
in-out property <string> voice-channel-name;
in-out property <string> activity-banner;
in-out property <[BookmarkItem]> bookmarks: [];
in-out property <string> address-input: "";
in-out property <string> status-text: "";
in-out property <bool> connecting: false;
in-out property <bool> auto-reconnect: false;
in-out property <string> node-name: "";
in-out property <string> identity-pubkey: "";
in-out property <bool> vault-enabled: false;
in-out property <string> voice-input-device: "";
in-out property <string> voice-output-device: "";
in-out property <float> voice-bitrate: 64.0;
in-out property <int> vad-mode: 0;
in-out property <float> volume: 100.0;
in-out property <string> active-tab: "audio";
callback channel-selected(string);
callback guild-selected(int);
callback dm-selected(string);
callback disconnect();
callback send-message(string, string);
callback load-more(string);
callback add-friend(string);
callback accept-friend(string);
callback decline-friend(string);
callback remove-friend(string);
callback start-dm(string);
callback toggle-mic();
callback toggle-deafen();
callback leave-voice();
callback connect(string);
callback remove-bookmark(string);
callback add-bookmark(string);
callback close-settings();
callback set-passphrase(string);
callback export-keyfile();
callback import-keyfile();
min-width: 900px;
min-height: 560px;
title: "Vnox";
background: Theme.bg-base;
if node-name == "" : ConnectScreen {
bookmarks: root.bookmarks;
address-input: root.address-input;
status-text: root.status-text;
connecting: root.connecting;
auto-reconnect: root.auto-reconnect;
node-name: root.node-name;
connect(address) => { root.connect(address); }
disconnect => { root.disconnect(); }
remove-bookmark(address) => { root.remove-bookmark(address); }
add-bookmark(address) => { root.add-bookmark(address); }
}
if node-name != "" : HorizontalLayout {
sidebar := Sidebar {
channels: root.channels;
guilds: root.guilds;
dm-conversations: root.dm-conversations;
username: root.username;
user-status: root.user-status;
active-channel-id: root.active-channel-id;
active-guild-idx: root.active-guild-idx;
channel-selected(id) => { root.channel-selected(id); }
guild-selected(idx) => { root.guild-selected(idx); }
dm-selected(id) => { root.dm-selected(id); }
disconnect => { root.disconnect(); }
}
chat-panel := ChatPanel {
visible: show-chat;
active-channel-id: root.active-channel-id;
active-channel-name: root.active-channel-name;
messages: root.messages;
members: root.members;
input-text: root.input-text;
is-dm: root.is-dm;
e2ee-enabled: root.e2ee-enabled;
dm-partner-name: root.dm-partner-name;
send-message(id, text) => { root.send-message(id, text); }
load-more(id) => { root.load-more(id); }
}
friends-panel := FriendsPanel {
visible: show-friends;
friends: root.friends;
pending-requests: root.pending-requests;
blocked-users: root.blocked-users;
active-tab: root.friends-active-tab;
add-friend-input: root.add-friend-input;
add-friend(id) => { root.add-friend(id); }
accept-friend(id) => { root.accept-friend(id); }
decline-friend(id) => { root.decline-friend(id); }
remove-friend(id) => { root.remove-friend(id); }
start-dm(id) => { root.start-dm(id); }
}
voice-panel := VoicePanel {
visible: show-voice;
members: root.voice-members;
mic-enabled: root.mic-enabled;
deafened: root.deafened;
channel-name: root.voice-channel-name;
activity-banner: root.activity-banner;
toggle-mic => { root.toggle-mic(); }
toggle-deafen => { root.toggle-deafen(); }
leave-voice => { root.leave-voice(); }
}
}
}

1
src/ui/slint/mod.rs Normal file
View file

@ -0,0 +1 @@
::slint::include_modules!();

View file

@ -0,0 +1,68 @@
import { Theme } from "theme.slint";
import { Button } from "std-widgets.slint";
export component SettingsWindow inherits Window {
title: "Settings";
width: 720px;
height: 520px;
background: Theme.bg-base;
in-out property <string> identity-pubkey;
in-out property <bool> vault-enabled;
in-out property <string> voice-input-device;
in-out property <string> voice-output-device;
in-out property <float> voice-bitrate;
in-out property <int> vad-mode;
in-out property <float> volume;
in-out property <string> active-tab;
callback close();
callback set-passphrase(string);
callback export-keyfile();
callback import-keyfile();
VerticalLayout {
HorizontalLayout {
Button { text: "Audio"; clicked => { active-tab = "audio"; } }
Button { text: "Network"; clicked => { active-tab = "network"; } }
Button { text: "Identity"; clicked => { active-tab = "identity"; } }
Button { text: "App"; clicked => { active-tab = "app"; } }
}
if active-tab == "identity": VerticalLayout {
Text { text: "Identity"; color: Theme.text-primary; font-size: 18px; }
Text { text: "Public Key: " + identity-pubkey; color: Theme.text-secondary; }
Button { text: vault-enabled ? "Remove Passphrase" : "Set Passphrase"; clicked => { set-passphrase(""); } }
Button { text: "Export Keyfile"; clicked => { export-keyfile(); } }
Button { text: "Import Keyfile"; clicked => { import-keyfile(); } }
}
if active-tab == "audio": VerticalLayout {
Text { text: "Voice Settings"; color: Theme.text-primary; font-size: 18px; }
Text { text: "Input: " + voice-input-device; color: Theme.text-secondary; }
Text { text: "Output: " + voice-output-device; color: Theme.text-secondary; }
HorizontalLayout {
Text { text: "Bitrate:"; color: Theme.text-secondary; }
Text { text: voice-bitrate; color: Theme.text-primary; }
}
HorizontalLayout {
Text { text: "VAD Mode:"; color: Theme.text-secondary; }
Text { text: vad-mode == 0 ? "PTT" : vad-mode == 1 ? "VAD" : "Always On"; color: Theme.text-primary; }
}
HorizontalLayout {
Text { text: "Volume:"; color: Theme.text-secondary; }
Text { text: volume; color: Theme.text-primary; }
}
}
if active-tab == "network": VerticalLayout {
Text { text: "Network"; color: Theme.text-primary; font-size: 18px; }
}
if active-tab == "app": VerticalLayout {
Text { text: "Appearance"; color: Theme.text-primary; font-size: 18px; }
}
Button { text: "Close"; horizontal-alignment: center; clicked => { close(); } }
}
}

View file

@ -0,0 +1,92 @@
import { Theme } from "theme.slint";
export struct ChannelItem {
channel_id: string,
name: string,
kind: string,
}
export struct GuildItem {
guild_id: string,
name: string,
}
export component Sidebar {
in-out property <[GuildItem]> guilds;
in-out property <[ChannelItem]> channels;
in-out property <[string]> dm-conversations;
in-out property <string> username;
in-out property <string> user-status;
in-out property <string> active-channel-id;
in-out property <int> active-guild-idx;
callback channel-selected(string);
callback guild-selected(int);
callback dm-selected(string);
callback disconnect();
width: 240px;
height: 100%;
Rectangle {
background: Theme.bg-base;
width: 100%;
height: 100%;
VerticalLayout {
Rectangle {
height: 48px;
background: Theme.bg-surface;
HorizontalLayout {
padding: 8px;
Text { text: "Vnox"; color: Theme.accent; font-size: 18px; }
}
}
VerticalLayout {
for item in channels: Rectangle {
height: 36px;
background: item.channel_id == active-channel-id ? Theme.bg-elevated : Theme.bg-base;
HorizontalLayout {
padding: 8px;
Text {
text: item.kind == "voice" ? "🔊 " : "# ";
color: Theme.text-secondary;
}
Text { text: item.name; color: Theme.text-primary; }
}
TouchArea {
clicked => { channel-selected(item.channel_id); }
}
}
}
VerticalLayout {
for dm in dm-conversations: Rectangle {
height: 36px;
HorizontalLayout {
padding: 8px;
Text { text: "@"; color: Theme.text-secondary; }
Text { text: dm; color: Theme.text-primary; }
}
TouchArea {
clicked => { dm-selected(dm); }
}
}
}
Rectangle {
height: 52px;
background: Theme.bg-surface;
HorizontalLayout {
padding: 8px;
Text { text: username; color: Theme.text-primary; }
Text { text: user-status; color: Theme.text-muted; }
TouchArea {
clicked => { disconnect(); }
}
}
}
}
}
}

15
src/ui/slint/theme.slint Normal file
View file

@ -0,0 +1,15 @@
export global Theme {
in-out property <color> bg-base: #181825;
in-out property <color> bg-surface: #1e1e2e;
in-out property <color> bg-hover: #2a2a3e;
in-out property <color> bg-elevated: #2a2a3e;
in-out property <color> bg-interactive: #313244;
in-out property <color> accent: #E67E22;
in-out property <color> text-primary: #cdd6f4;
in-out property <color> text-secondary: #a0a4b8;
in-out property <color> text-muted: #6c7086;
in-out property <color> text-disabled: #45475a;
in-out property <color> danger: #f38ba8;
in-out property <color> success: #a6e3a1;
in-out property <length> radius: 8px;
}

99
src/ui/slint/voice.slint Normal file
View file

@ -0,0 +1,99 @@
import { Theme } from "theme.slint";
export struct VoiceMemberItem {
user_id: string,
nickname: string,
speaking: bool,
}
export component VoicePanel {
in-out property <[VoiceMemberItem]> members;
in-out property <bool> mic-enabled;
in-out property <bool> deafened;
in-out property <string> channel-name;
in-out property <string> activity-banner;
callback toggle-mic();
callback toggle-deafen();
callback leave-voice();
VerticalLayout {
padding: 16px;
spacing: 8px;
Text {
text: "🔊 " + channel-name;
color: Theme.text-primary;
}
Text {
text: activity-banner;
color: Theme.text-muted;
}
Flickable {
VerticalLayout {
for m in members: Rectangle {
height: 48px;
HorizontalLayout {
padding: 8px;
spacing: 8px;
Rectangle {
width: 12px;
height: 12px;
border-radius: 6px;
background: m.speaking ? Theme.success : Theme.text-muted;
}
Text {
text: m.nickname;
color: Theme.text-primary;
}
}
}
}
}
HorizontalLayout {
spacing: 8px;
Rectangle {
height: 32px;
background: Theme.accent;
border-radius: Theme.radius;
TouchArea {
clicked => { toggle-mic(); }
}
Text {
text: mic-enabled ? "Mute" : "Unmute";
color: Theme.text-primary;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
height: 32px;
background: Theme.accent;
border-radius: Theme.radius;
TouchArea {
clicked => { toggle-deafen(); }
}
Text {
text: deafened ? "Undeafen" : "Deafen";
color: Theme.text-primary;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
height: 32px;
background: Theme.danger;
border-radius: Theme.radius;
TouchArea {
clicked => { leave-voice(); }
}
Text {
text: "Leave";
color: Theme.text-primary;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}

View file

@ -1,7 +1,6 @@
use std::collections::{HashMap, HashSet};
use std::time::Instant;
use std::collections::HashMap;
use super::models::{default_bookmarks, now_utc_hms};
use super::models::default_bookmarks;
use super::types::{ConnState, UiState, VoiceUiState};
impl Default for UiState {
@ -16,131 +15,42 @@ impl Default for UiState {
active_channel: None,
messages: Default::default(),
chat_input: String::new(),
editing_message_id: None,
replying_to: None,
context_menu_message_id: None,
scroll_bottom: false,
voice: VoiceUiState::Off,
voice_joined_at: None,
user_names: Default::default(),
server_channel: None,
rtt_ms: None,
settings_open: false,
settings_page: Default::default(),
mic_enabled: true,
deafen: false,
noise_suppress: true,
echo_cancel: true,
vad_mode: 0,
vad_threshold: 40.0,
voice_bitrate: 64.0,
packet_interval: 1,
mic_device: 0,
input_volume: 80.0,
input_device_labels: vec!["Default".into()],
input_device_metas: Vec::new(),
output_device_labels: vec!["Default".into()],
output_device_metas: Vec::new(),
last_device_scan: None,
output_device: 0,
output_volume: 90.0,
jitter_size: 1,
adaptive_buffer: true,
overlay_enabled: false,
overlay_speakers: true,
overlay_latency: true,
relay_enabled: true,
relay_address: "relay.nightcore.lnex".into(),
auto_relay: true,
force_relay: false,
udp_port: "7700".into(),
auto_reconnect: true,
ui_scale: 100.0,
kb_ptt: "mouse4".into(),
kb_mute: "ctrl+m".into(),
kb_deafen: "ctrl+d".into(),
kb_overlay: "ctrl+shift+o".into(),
log_level: 0,
log_voice_packets: false,
show_packet_stats: false,
disable_encryption: false,
guilds: Vec::new(),
active_guild_id: None,
presences: Default::default(),
presence_status: Some("online".into()),
custom_status: None,
activity_type: None,
activity_text: None,
friends: Vec::new(),
pending_friend_requests: Vec::new(),
blocked_users: Vec::new(),
block_input: None,
block_list_fetched_at: None,
dm_conversations: Vec::new(),
dm_messages: Default::default(),
active_dm_id: None,
new_dm_input: None,
add_server_open: false,
add_server_input: String::new(),
invite_accept_open: false,
invite_accept_code: String::new(),
create_guild_open: false,
create_guild_input: String::new(),
create_channel_open: false,
create_channel_input: String::new(),
create_channel_kind: "text".into(),
vault_set_open: false,
vault_remove_open: false,
vault_passphrase_input: String::new(),
vault_passphrase_confirm: String::new(),
vault_error: None,
export_keyfile_open: false,
export_keyfile_pass: String::new(),
export_keyfile_confirm: String::new(),
export_keyfile_output: String::new(),
export_keyfile_error: None,
import_keyfile_open: false,
import_keyfile_input: String::new(),
import_keyfile_pass: String::new(),
import_keyfile_error: None,
audit_log_open: false,
audit_log_guild_id: None,
audit_log_entries: Vec::new(),
guild_members_open: false,
guild_member_list_guild_id: None,
guild_member_list: Vec::new(),
guild_role_list_guild_id: None,
guild_role_list: Vec::new(),
add_friend_open: false,
add_friend_input: String::new(),
dm_search_query: String::new(),
user_colors: HashMap::new(),
per_user_volumes: Default::default(),
kb_listening: None,
private_mode: false,
collapsed_categories: HashSet::new(),
startup_time_str: now_utc_hms(),
typing_users: HashMap::new(),
last_typing_send: Instant::now(),
read_receipts: HashMap::new(),
friends_tab: "all".into(),
local_speaking: false,

View file

@ -2,57 +2,12 @@ mod default;
mod models;
mod types;
pub use models::{
Channel, ChatMessage, DmConversation, FriendState, GuildState, NodeBookmark, SettingsPage,
};
pub use types::{ConnState, TYPING_TIMEOUT, UiState, VoiceUiState};
use std::time::{Duration, Instant};
pub use models::{Channel, ChatMessage, DmConversation, FriendState, GuildState, NodeBookmark};
pub use types::{ConnState, UiState, VoiceUiState};
use models::bookmark_label_from_address;
impl UiState {
pub fn refresh_audio_devices(&mut self) {
let now = Instant::now();
if let Some(last) = self.last_device_scan
&& now.duration_since(last) < Duration::from_millis(2500)
{
return;
}
self.last_device_scan = Some(now);
match vnox_client::audio::devices::enumerate() {
Ok(lists) => {
self.input_device_labels = lists.input_labels;
self.input_device_metas = lists.input_devices;
self.output_device_labels = lists.output_labels;
self.output_device_metas = lists.output_devices;
}
Err(e) => {
tracing::warn!("audio device enumeration: {e}");
}
}
if self.input_device_labels.is_empty() {
self.input_device_labels.push("Default".into());
}
if self.output_device_labels.is_empty() {
self.output_device_labels.push("Default".into());
}
self.mic_device = self
.mic_device
.min(self.input_device_labels.len().saturating_sub(1));
self.output_device = self
.output_device
.min(self.output_device_labels.len().saturating_sub(1));
}
pub fn force_refresh_audio_devices(&mut self) {
self.last_device_scan = None;
self.refresh_audio_devices();
}
pub fn remove_bookmark(&mut self, index: usize) {
if index < self.bookmarks.len() {
self.bookmarks.remove(index);
@ -73,11 +28,6 @@ impl UiState {
true
}
pub fn voice_elapsed(&self) -> Option<String> {
let secs = self.voice_joined_at?.elapsed().as_secs();
Some(format!("{}:{:02}", secs / 60, secs % 60))
}
pub fn nick_for<'a>(&'a self, sender_id: &'a str) -> &'a str {
self.user_names
.get(sender_id)
@ -85,12 +35,6 @@ impl UiState {
.unwrap_or(sender_id)
}
pub fn active_voice_channel(&self) -> Option<&Channel> {
self.channels
.iter()
.find(|c| matches!(&self.voice, VoiceUiState::Active { channel } if channel == &c.id))
}
pub fn seed_default_channels(&mut self) {
if !self.channels.is_empty() {
return;

View file

@ -1,19 +1,5 @@
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Default)]
pub enum SettingsPage {
Identity,
#[default]
Voice,
AudioOutput,
Network,
Overlay,
Appearance,
Keybinds,
Plugins,
Advanced,
}
#[derive(Debug, Clone)]
pub struct Channel {
pub id: String,
@ -38,16 +24,17 @@ pub struct ChatMessage {
#[derive(Debug, Clone)]
pub struct DmConversation {
pub dm_id: String,
pub other_user_id: String,
pub other_nickname: String,
pub unread_count: u32,
pub e2ee_enabled: bool,
pub e2ee_peer_public_key: Option<Vec<u8>>,
pub e2ee_shared_secret: Option<[u8; 32]>,
}
#[derive(Debug, Clone)]
pub struct GuildState {
pub guild_id: String,
pub name: String,
pub owner_id: String,
pub member_count: u32,
}
@ -55,8 +42,6 @@ pub struct GuildState {
pub struct FriendState {
pub user_id: String,
pub nickname: String,
pub status: String,
pub since: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -79,14 +64,3 @@ pub(crate) fn bookmark_label_from_address(address: &str) -> String {
.take(12)
.collect()
}
pub(crate) fn now_utc_hms() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let h = (secs % 86400) / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
format!("{h:02}:{m:02}:{s:02}")
}

View file

@ -1,7 +1,5 @@
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
pub const TYPING_TIMEOUT: Duration = Duration::from_secs(4);
use std::collections::HashMap;
use std::time::Instant;
#[derive(Debug, Clone, PartialEq, Default)]
pub enum VoiceUiState {
@ -37,169 +35,54 @@ pub struct UiState {
pub active_channel: Option<String>,
pub messages: HashMap<String, Vec<super::ChatMessage>>,
pub chat_input: String,
pub editing_message_id: Option<String>,
/// When set, the next sent message will be a reply to this message_id.
pub replying_to: Option<(String, String)>, // (message_id, sender_id)
/// Message currently shown in the context menu (right-click).
pub context_menu_message_id: Option<String>,
pub scroll_bottom: bool,
pub voice: VoiceUiState,
pub voice_joined_at: Option<Instant>,
pub user_names: HashMap<String, String>,
pub server_channel: Option<String>,
pub rtt_ms: Option<u32>,
pub settings_open: bool,
pub settings_page: super::SettingsPage,
pub mic_enabled: bool,
pub deafen: bool,
pub noise_suppress: bool,
pub echo_cancel: bool,
pub vad_mode: usize,
pub vad_threshold: f32,
pub voice_bitrate: f32,
pub packet_interval: usize,
pub mic_device: usize,
pub input_volume: f32,
pub input_device_labels: Vec<String>,
pub output_device_labels: Vec<String>,
pub input_device_metas: Vec<vnox_client::audio::devices::DeviceLabel>,
pub output_device_metas: Vec<vnox_client::audio::devices::DeviceLabel>,
pub last_device_scan: Option<Instant>,
pub output_device: usize,
pub output_volume: f32,
pub jitter_size: usize,
pub adaptive_buffer: bool,
pub overlay_enabled: bool,
pub overlay_speakers: bool,
pub overlay_latency: bool,
pub relay_enabled: bool,
pub relay_address: String,
pub auto_relay: bool,
pub force_relay: bool,
pub udp_port: String,
pub auto_reconnect: bool,
pub ui_scale: f32,
pub kb_ptt: String,
pub kb_mute: String,
pub kb_deafen: String,
pub kb_overlay: String,
pub log_level: usize,
pub log_voice_packets: bool,
pub show_packet_stats: bool,
pub disable_encryption: bool,
pub private_mode: bool,
pub guilds: Vec<super::GuildState>,
pub active_guild_id: Option<String>,
pub presences: HashMap<String, crate::net::PresenceInfo>,
pub presence_status: Option<String>,
/// Free-form custom status text ("making tea").
pub custom_status: Option<String>,
/// Activity type ("playing", "listening", "watching", "streaming").
pub activity_type: Option<String>,
/// Activity text (e.g. "Counter-Strike 2").
pub activity_text: Option<String>,
pub friends: Vec<super::FriendState>,
pub pending_friend_requests: Vec<super::FriendState>,
/// Blocked user IDs.
pub blocked_users: Vec<String>,
/// Input buffer for the block-user text field.
pub block_input: Option<String>,
/// When the blocked list was last fetched from the server.
pub block_list_fetched_at: Option<Instant>,
pub dm_conversations: Vec<super::DmConversation>,
pub dm_messages: HashMap<String, Vec<super::ChatMessage>>,
pub active_dm_id: Option<String>,
pub new_dm_input: Option<String>,
pub dm_search_query: String,
pub user_colors: HashMap<String, eframe::egui::Color32>,
pub per_user_volumes: HashMap<String, f32>,
pub add_server_open: bool,
pub add_server_input: String,
pub invite_accept_open: bool,
pub invite_accept_code: String,
pub create_guild_open: bool,
pub create_guild_input: String,
/// "Create a Channel" popup state.
pub create_channel_open: bool,
pub create_channel_input: String,
pub create_channel_kind: String,
/// Vault UI state — set/remove passphrase modals.
pub vault_set_open: bool,
pub vault_remove_open: bool,
pub vault_passphrase_input: String,
pub vault_passphrase_confirm: String,
pub vault_error: Option<String>,
/// Export/import keyfile UI state.
pub export_keyfile_open: bool,
pub export_keyfile_pass: String,
pub export_keyfile_confirm: String,
pub export_keyfile_output: String,
pub export_keyfile_error: Option<String>,
pub import_keyfile_open: bool,
pub import_keyfile_input: String,
pub import_keyfile_pass: String,
pub import_keyfile_error: Option<String>,
/// Audit log viewer state.
pub audit_log_open: bool,
pub audit_log_guild_id: Option<String>,
pub audit_log_entries: Vec<vnox_client::net::payloads::AuditLogEntryPayload>,
/// Guild member list viewer state.
pub guild_members_open: bool,
pub guild_member_list_guild_id: Option<String>,
pub guild_member_list: Vec<vnox_client::net::payloads::GuildMemberInfoPayload>,
/// Guild role list state.
pub guild_role_list_guild_id: Option<String>,
pub guild_role_list: Vec<vnox_client::net::payloads::GuildRoleInfoPayload>,
pub add_friend_open: bool,
pub add_friend_input: String,
pub kb_listening: Option<String>,
pub collapsed_categories: HashSet<String>,
pub startup_time_str: String,
pub user_colors: HashMap<String, String>,
pub typing_users: HashMap<String, Vec<(String, Instant)>>,
pub last_typing_send: Instant,
pub read_receipts: HashMap<String, HashMap<String, String>>,
/// Active tab in the Friends panel ("online" / "all" / "pending" / "blocked").
pub friends_tab: String,
/// True when local user is transmitting voice (PTT held or VAD above threshold).
pub local_speaking: bool,
/// True when a remote peer's voice packet arrived in the last ~500ms.
pub remote_speaking: bool,
/// Most recently received voice-packet sender's user_id (hex pubkey).
/// Empty when no remote user has spoken recently.
pub last_remote_speaker_id: String,
/// UNIX millisecond timestamp of the last voice packet from any remote peer.
pub last_voice_packet_ms: u64,
}

View file

@ -1,710 +0,0 @@
//! Hand-drawn vector icons rendered via `egui::Painter`.
//!
//! Each icon is a function `draw_<name>(painter, rect, color)` that fills
//! the given `egui::Rect` with a monochrome glyph of the given `Color32`.
//!
//! All glyphs are designed on a 24×24 grid normalized to the rect.
//! Replace emoji usage throughout the UI with these for a cleaner, more
//! professional look that doesn't depend on system emoji fonts.
use eframe::egui::{self, Color32, Pos2, Rect, Shape, Stroke, Vec2};
/// Draw an icon centered in the given rect, scaled to fit.
pub fn draw_icon(
ui: &mut egui::Ui,
size: f32,
draw_fn: impl FnOnce(&egui::Painter, Rect, Color32),
) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover());
if ui.is_rect_visible(rect) {
let painter = ui.painter();
let s = rect.size().min_elem();
let center = rect.center();
let half = s / 2.0;
let scaled =
Rect::from_min_size(Pos2::new(center.x - half, center.y - half), Vec2::splat(s));
draw_fn(painter, scaled, super::TEXT_MUTED);
}
resp
}
/// Helper to convert a 0..24 coordinate into the scaled rect's coordinate.
fn p(rect: Rect, x: f32, y: f32) -> Pos2 {
let sx = rect.width() / 24.0;
let sy = rect.height() / 24.0;
Pos2::new(rect.min.x + x * sx, rect.min.y + y * sy)
}
/// Microphone icon.
pub fn microphone(painter: &egui::Painter, rect: Rect, color: Color32) {
// Mic body (rounded capsule).
let body = Rect::from_center_size(
p(rect, 12.0, 9.0),
Vec2::new(rect.width() * 0.18, rect.height() * 0.42),
);
painter.rect_filled(body, egui::CornerRadius::same(4), color);
// Arc below (the cradle).
let arc_center = p(rect, 12.0, 14.0);
let radius = rect.width() * 0.22;
painter.add(Shape::circle_stroke(
arc_center,
radius,
Stroke::new(rect.width() * 0.05, color),
));
// Stand.
painter.line_segment(
[p(rect, 12.0, 16.0), p(rect, 12.0, 20.0)],
Stroke::new(rect.width() * 0.06, color),
);
painter.line_segment(
[p(rect, 9.0, 20.0), p(rect, 15.0, 20.0)],
Stroke::new(rect.width() * 0.06, color),
);
}
/// Microphone-slash icon (mic with a diagonal line through it).
pub fn microphone_off(painter: &egui::Painter, rect: Rect, color: Color32) {
microphone(painter, rect, Color32::from_rgb(80, 80, 80));
painter.line_segment(
[p(rect, 5.0, 5.0), p(rect, 19.0, 19.0)],
Stroke::new(rect.width() * 0.08, color),
);
}
/// Headphones icon (for deafen).
pub fn headphones(painter: &egui::Painter, rect: Rect, color: Color32) {
// Top arc.
painter.add(Shape::circle_stroke(
p(rect, 12.0, 13.0),
rect.width() * 0.30,
Stroke::new(rect.width() * 0.06, color),
));
// Cover bottom half of the arc so only top shows.
painter.rect_filled(
Rect::from_min_max(p(rect, 4.0, 13.0), p(rect, 20.0, 21.0)),
egui::CornerRadius::same(0),
super::BG_ELEVATED,
);
// Left ear cup.
painter.rect_filled(
Rect::from_center_size(
p(rect, 5.5, 13.0),
Vec2::new(rect.width() * 0.18, rect.height() * 0.28),
),
egui::CornerRadius::same(2),
color,
);
// Right ear cup.
painter.rect_filled(
Rect::from_center_size(
p(rect, 18.5, 13.0),
Vec2::new(rect.width() * 0.18, rect.height() * 0.28),
),
egui::CornerRadius::same(2),
color,
);
}
/// Headphones-off (with slash).
pub fn headphones_off(painter: &egui::Painter, rect: Rect, color: Color32) {
headphones(painter, rect, Color32::from_rgb(80, 80, 80));
painter.line_segment(
[p(rect, 5.0, 5.0), p(rect, 19.0, 19.0)],
Stroke::new(rect.width() * 0.08, color),
);
}
/// Gear / settings icon (cog).
pub fn gear(painter: &egui::Painter, rect: Rect, color: Color32) {
let center = p(rect, 12.0, 12.0);
let outer_r = rect.width() * 0.40;
let inner_r = rect.width() * 0.28;
let hole_r = rect.width() * 0.12;
// Draw 8 teeth as small rounded rects around the circle.
for i in 0..8 {
let angle = i as f32 * std::f32::consts::TAU / 8.0;
let tooth_center = Pos2::new(
center.x + angle.cos() * (outer_r - rect.width() * 0.04),
center.y + angle.sin() * (outer_r - rect.width() * 0.04),
);
painter.rect_filled(
Rect::from_center_size(tooth_center, Vec2::splat(rect.width() * 0.10)),
egui::CornerRadius::same(1),
color,
);
}
// Outer ring.
painter.add(Shape::circle_stroke(
center,
inner_r,
Stroke::new(rect.width() * 0.07, color),
));
// Center hole.
painter.circle_filled(center, hole_r, color);
}
/// Plus icon.
pub fn plus(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.08;
let h = rect.height() * 0.6;
let v = rect.width() * 0.6;
let hv = rect.height() * 0.08;
painter.rect_filled(
Rect::from_center_size(rect.center(), Vec2::new(w, h)),
egui::CornerRadius::same(1),
color,
);
painter.rect_filled(
Rect::from_center_size(rect.center(), Vec2::new(v, hv)),
egui::CornerRadius::same(1),
color,
);
}
/// X / close icon.
pub fn close(painter: &egui::Painter, rect: Rect, color: Color32) {
let stroke = Stroke::new(rect.width() * 0.08, color);
painter.line_segment([p(rect, 6.0, 6.0), p(rect, 18.0, 18.0)], stroke);
painter.line_segment([p(rect, 18.0, 6.0), p(rect, 6.0, 18.0)], stroke);
}
/// Trash icon.
pub fn trash(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.06;
// Lid.
painter.line_segment(
[p(rect, 5.0, 7.0), p(rect, 19.0, 7.0)],
Stroke::new(w, color),
);
// Handle.
painter.line_segment(
[p(rect, 10.0, 5.0), p(rect, 14.0, 5.0)],
Stroke::new(w, color),
);
// Body.
painter.line_segment(
[p(rect, 7.0, 7.0), p(rect, 8.5, 19.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 17.0, 7.0), p(rect, 15.5, 19.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 8.5, 19.0), p(rect, 15.5, 19.0)],
Stroke::new(w, color),
);
// Ribs.
painter.line_segment(
[p(rect, 11.0, 9.0), p(rect, 11.0, 17.0)],
Stroke::new(w * 0.7, color),
);
painter.line_segment(
[p(rect, 13.0, 9.0), p(rect, 13.0, 17.0)],
Stroke::new(w * 0.7, color),
);
}
/// Lock icon (closed padlock).
pub fn lock(painter: &egui::Painter, rect: Rect, color: Color32) {
// Shackle (arc).
painter.add(Shape::circle_stroke(
p(rect, 12.0, 9.0),
rect.width() * 0.18,
Stroke::new(rect.width() * 0.07, color),
));
// Cover the bottom of the shackle arc.
painter.rect_filled(
Rect::from_min_max(p(rect, 9.0, 9.0), p(rect, 15.0, 13.0)),
egui::CornerRadius::same(0),
super::BG_ELEVATED,
);
// Body.
painter.rect_filled(
Rect::from_center_size(
p(rect, 12.0, 15.0),
Vec2::new(rect.width() * 0.42, rect.height() * 0.28),
),
egui::CornerRadius::same(2),
color,
);
}
/// Unlock icon (open padlock).
pub fn unlock(painter: &egui::Painter, rect: Rect, color: Color32) {
// Shackle (open — arc shifted right).
painter.add(Shape::circle_stroke(
p(rect, 14.0, 9.0),
rect.width() * 0.18,
Stroke::new(rect.width() * 0.07, color),
));
// Cover bottom of shackle.
painter.rect_filled(
Rect::from_min_max(p(rect, 11.0, 9.0), p(rect, 17.0, 13.0)),
egui::CornerRadius::same(0),
super::BG_ELEVATED,
);
// Body.
painter.rect_filled(
Rect::from_center_size(
p(rect, 10.0, 15.0),
Vec2::new(rect.width() * 0.42, rect.height() * 0.28),
),
egui::CornerRadius::same(2),
color,
);
}
/// Refresh / rotate icon (circular arrow).
pub fn refresh(painter: &egui::Painter, rect: Rect, color: Color32) {
let center = p(rect, 12.0, 12.0);
let r = rect.width() * 0.32;
let stroke = Stroke::new(rect.width() * 0.07, color);
// Arc (3/4 circle).
let steps = 24;
let start = -std::f32::consts::FRAC_PI_2;
let end = start + std::f32::consts::TAU * 0.75;
let mut prev: Option<Pos2> = None;
for i in 0..=steps {
let t = start + (end - start) * (i as f32 / steps as f32);
let pt = Pos2::new(center.x + t.cos() * r, center.y + t.sin() * r);
if let Some(p0) = prev {
painter.line_segment([p0, pt], stroke);
}
prev = Some(pt);
}
// Arrowhead at the end.
let arrow_end = prev.unwrap();
let perp = end + std::f32::consts::FRAC_PI_2;
let ah1 = Pos2::new(
arrow_end.x + perp.cos() * rect.width() * 0.10,
arrow_end.y + perp.sin() * rect.height() * 0.10,
);
let ah2 = Pos2::new(
arrow_end.x - perp.cos() * rect.width() * 0.10,
arrow_end.y - perp.sin() * rect.height() * 0.10,
);
painter.line_segment([arrow_end, ah1], stroke);
painter.line_segment([arrow_end, ah2], stroke);
}
/// Clipboard / list icon (for audit log).
pub fn clipboard(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.06;
// Outer rect.
painter.rect_stroke(
Rect::from_min_max(p(rect, 5.0, 4.0), p(rect, 19.0, 20.0)),
egui::CornerRadius::same(2),
Stroke::new(w, color),
egui::StrokeKind::Middle,
);
// Clip at top.
painter.rect_filled(
Rect::from_min_max(p(rect, 9.0, 3.0), p(rect, 15.0, 6.0)),
egui::CornerRadius::same(1),
color,
);
// Lines.
painter.line_segment(
[p(rect, 8.0, 10.0), p(rect, 16.0, 10.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 8.0, 13.0), p(rect, 16.0, 13.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 8.0, 16.0), p(rect, 14.0, 16.0)],
Stroke::new(w, color),
);
}
/// People / members icon (two overlapping circles + shoulders).
pub fn people(painter: &egui::Painter, rect: Rect, color: Color32) {
let r1 = rect.width() * 0.16;
let r2 = rect.width() * 0.13;
// Two heads.
painter.circle_filled(p(rect, 9.0, 8.0), r1, color);
painter.circle_filled(p(rect, 16.0, 9.0), r2, color);
// Shoulders.
painter.rect_filled(
Rect::from_min_max(p(rect, 4.0, 13.0), p(rect, 14.0, 21.0)),
egui::CornerRadius::same(4),
color,
);
painter.rect_filled(
Rect::from_min_max(p(rect, 12.0, 14.0), p(rect, 20.0, 21.0)),
egui::CornerRadius::same(4),
color,
);
}
/// Crown icon (for owner).
pub fn crown(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.06;
// Base bar.
painter.line_segment(
[p(rect, 5.0, 18.0), p(rect, 19.0, 18.0)],
Stroke::new(w * 1.5, color),
);
// Three peaks.
painter.line_segment(
[p(rect, 5.0, 18.0), p(rect, 7.0, 8.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 7.0, 8.0), p(rect, 12.0, 14.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 12.0, 14.0), p(rect, 17.0, 8.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 17.0, 8.0), p(rect, 19.0, 18.0)],
Stroke::new(w, color),
);
// Gems.
painter.circle_filled(p(rect, 7.0, 7.0), rect.width() * 0.04, color);
painter.circle_filled(p(rect, 17.0, 7.0), rect.width() * 0.04, color);
painter.circle_filled(p(rect, 12.0, 13.0), rect.width() * 0.04, color);
}
/// Boot icon (for kick).
pub fn boot(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.07;
// Boot outline.
painter.line_segment(
[p(rect, 8.0, 5.0), p(rect, 8.0, 14.0)],
Stroke::new(w * 1.5, color),
);
painter.line_segment(
[p(rect, 8.0, 14.0), p(rect, 8.0, 17.0)],
Stroke::new(w * 1.5, color),
);
painter.line_segment(
[p(rect, 7.0, 17.0), p(rect, 18.0, 17.0)],
Stroke::new(w * 1.5, color),
);
painter.line_segment(
[p(rect, 18.0, 17.0), p(rect, 18.0, 14.0)],
Stroke::new(w * 1.5, color),
);
painter.line_segment(
[p(rect, 18.0, 14.0), p(rect, 12.0, 14.0)],
Stroke::new(w * 1.5, color),
);
painter.line_segment(
[p(rect, 12.0, 14.0), p(rect, 12.0, 5.0)],
Stroke::new(w * 1.5, color),
);
// Top opening.
painter.line_segment(
[p(rect, 8.0, 5.0), p(rect, 12.0, 5.0)],
Stroke::new(w, color),
);
}
/// Speaker (sound waves) icon — used for voice activity.
pub fn speaker(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.06;
// Speaker box.
painter.line_segment(
[p(rect, 4.0, 10.0), p(rect, 8.0, 10.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 4.0, 14.0), p(rect, 8.0, 14.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 4.0, 10.0), p(rect, 4.0, 14.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 8.0, 10.0), p(rect, 12.0, 6.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 8.0, 14.0), p(rect, 12.0, 18.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 12.0, 6.0), p(rect, 12.0, 18.0)],
Stroke::new(w, color),
);
// Sound waves.
painter.add(Shape::circle_stroke(
p(rect, 15.0, 12.0),
rect.width() * 0.10,
Stroke::new(w * 0.8, color),
));
painter.add(Shape::circle_stroke(
p(rect, 17.0, 12.0),
rect.width() * 0.16,
Stroke::new(w * 0.6, color),
));
}
/// Speaker-muted icon (speaker with X).
pub fn speaker_off(painter: &egui::Painter, rect: Rect, color: Color32) {
speaker(painter, rect, Color32::from_rgb(80, 80, 80));
let w = rect.width() * 0.07;
painter.line_segment(
[p(rect, 14.0, 8.0), p(rect, 20.0, 16.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 20.0, 8.0), p(rect, 14.0, 16.0)],
Stroke::new(w, color),
);
}
/// Envelope icon (for DM).
pub fn envelope(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.06;
// Outer rect.
painter.rect_stroke(
Rect::from_min_max(p(rect, 4.0, 6.0), p(rect, 20.0, 18.0)),
egui::CornerRadius::same(1),
Stroke::new(w, color),
egui::StrokeKind::Middle,
);
// Flap.
painter.line_segment(
[p(rect, 4.0, 6.0), p(rect, 12.0, 13.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 20.0, 6.0), p(rect, 12.0, 13.0)],
Stroke::new(w, color),
);
}
/// Pencil / edit icon.
pub fn pencil(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.06;
// Diagonal pencil body.
painter.line_segment(
[p(rect, 5.0, 19.0), p(rect, 16.0, 8.0)],
Stroke::new(w * 1.5, color),
);
// Tip.
painter.line_segment(
[p(rect, 4.0, 20.0), p(rect, 6.0, 18.0)],
Stroke::new(w * 1.5, color),
);
// Eraser end.
painter.line_segment(
[p(rect, 15.0, 7.0), p(rect, 18.0, 10.0)],
Stroke::new(w * 1.5, color),
);
painter.line_segment(
[p(rect, 16.0, 6.0), p(rect, 19.0, 9.0)],
Stroke::new(w * 1.5, color),
);
}
/// Reply arrow icon.
pub fn reply(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.07;
// Curved arrow (drawn as two segments).
painter.line_segment(
[p(rect, 16.0, 8.0), p(rect, 8.0, 8.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 8.0, 8.0), p(rect, 8.0, 16.0)],
Stroke::new(w, color),
);
// Arrowhead.
painter.line_segment(
[p(rect, 5.0, 11.0), p(rect, 8.0, 8.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 8.0, 8.0), p(rect, 11.0, 11.0)],
Stroke::new(w, color),
);
}
/// Copy icon (two overlapping squares).
pub fn copy(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.06;
// Back square.
painter.rect_stroke(
Rect::from_min_max(p(rect, 8.0, 4.0), p(rect, 20.0, 16.0)),
egui::CornerRadius::same(1),
Stroke::new(w, color),
egui::StrokeKind::Middle,
);
// Front square.
painter.rect_stroke(
Rect::from_min_max(p(rect, 4.0, 8.0), p(rect, 16.0, 20.0)),
egui::CornerRadius::same(1),
Stroke::new(w, color),
egui::StrokeKind::Middle,
);
}
/// User-plus icon (for "add friend").
pub fn user_plus(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.06;
// Head.
painter.circle_filled(p(rect, 9.0, 8.0), rect.width() * 0.14, color);
// Shoulders.
painter.rect_filled(
Rect::from_min_max(p(rect, 4.0, 13.0), p(rect, 14.0, 21.0)),
egui::CornerRadius::same(4),
color,
);
// Plus.
painter.line_segment(
[p(rect, 16.0, 11.0), p(rect, 20.0, 11.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 18.0, 9.0), p(rect, 18.0, 13.0)],
Stroke::new(w, color),
);
}
/// Home icon.
pub fn home(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.06;
// Roof.
painter.line_segment(
[p(rect, 4.0, 12.0), p(rect, 12.0, 5.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 12.0, 5.0), p(rect, 20.0, 12.0)],
Stroke::new(w, color),
);
// Body.
painter.line_segment(
[p(rect, 6.0, 12.0), p(rect, 6.0, 19.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 18.0, 12.0), p(rect, 18.0, 19.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 6.0, 19.0), p(rect, 18.0, 19.0)],
Stroke::new(w, color),
);
// Door.
painter.line_segment(
[p(rect, 10.0, 19.0), p(rect, 10.0, 14.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 14.0, 19.0), p(rect, 14.0, 14.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 10.0, 14.0), p(rect, 14.0, 14.0)],
Stroke::new(w, color),
);
}
/// Link icon (for invites).
pub fn link(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.07;
// Left chain.
painter.rect_stroke(
Rect::from_min_max(p(rect, 3.0, 8.0), p(rect, 12.0, 16.0)),
egui::CornerRadius::same(4),
Stroke::new(w, color),
egui::StrokeKind::Middle,
);
// Right chain.
painter.rect_stroke(
Rect::from_min_max(p(rect, 12.0, 8.0), p(rect, 21.0, 16.0)),
egui::CornerRadius::same(4),
Stroke::new(w, color),
egui::StrokeKind::Middle,
);
// Connector.
painter.line_segment(
[p(rect, 9.0, 12.0), p(rect, 15.0, 12.0)],
Stroke::new(w, color),
);
}
/// Tag icon (for roles).
pub fn tag(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.06;
// Tag body.
let pts = [
p(rect, 4.0, 12.0),
p(rect, 8.0, 6.0),
p(rect, 19.0, 6.0),
p(rect, 19.0, 18.0),
p(rect, 8.0, 18.0),
];
for i in 0..pts.len() - 1 {
painter.line_segment([pts[i], pts[i + 1]], Stroke::new(w, color));
}
painter.line_segment([pts[pts.len() - 1], pts[0]], Stroke::new(w, color));
// Hole.
painter.circle_filled(p(rect, 15.0, 12.0), rect.width() * 0.04, color);
}
/// Check / tick icon.
pub fn check(painter: &egui::Painter, rect: Rect, color: Color32) {
painter.line_segment(
[p(rect, 5.0, 12.0), p(rect, 10.0, 17.0)],
Stroke::new(rect.width() * 0.10, color),
);
painter.line_segment(
[p(rect, 10.0, 17.0), p(rect, 19.0, 6.0)],
Stroke::new(rect.width() * 0.10, color),
);
}
/// Hash icon (for text channel).
pub fn hash(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.07;
painter.line_segment(
[p(rect, 8.0, 4.0), p(rect, 6.0, 20.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 16.0, 4.0), p(rect, 14.0, 20.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 4.0, 9.0), p(rect, 18.0, 9.0)],
Stroke::new(w, color),
);
painter.line_segment(
[p(rect, 3.0, 15.0), p(rect, 17.0, 15.0)],
Stroke::new(w, color),
);
}
/// Waveform icon (for voice channel).
pub fn waveform(painter: &egui::Painter, rect: Rect, color: Color32) {
let w = rect.width() * 0.07;
let cy = rect.center().y;
let h = rect.height();
let bars = [
(6.0_f32, 0.25_f32),
(9.0, 0.55),
(12.0, 0.85),
(15.0, 0.55),
(18.0, 0.25),
];
for (x, frac) in bars {
let bh = h * frac * 0.7;
painter.line_segment(
[
Pos2::new(rect.min.x + x * rect.width() / 24.0, cy - bh / 2.0),
Pos2::new(rect.min.x + x * rect.width() / 24.0, cy + bh / 2.0),
],
Stroke::new(w, color),
);
}
}

View file

@ -1,144 +0,0 @@
pub mod icons;
pub mod widgets;
pub use widgets::{avatar, icon_btn, icon_btn_drawn};
use eframe::egui::{self, Color32, CornerRadius, FontDefinitions, Stroke, Visuals};
pub const BG_BASE: Color32 = Color32::from_rgb(13, 13, 13);
pub const BG_SURFACE: Color32 = Color32::from_rgb(22, 22, 22);
pub const BG_ELEVATED: Color32 = Color32::from_rgb(28, 28, 28);
pub const BG_INTERACTIVE: Color32 = Color32::from_rgb(30, 30, 30);
pub const ACCENT: Color32 = Color32::from_rgb(230, 126, 34);
pub const ACCENT_HOVER: Color32 = Color32::from_rgb(245, 145, 55);
pub const ACCENT_SOFT: Color32 = Color32::from_rgb(200, 110, 30);
pub const ACCENT_10: Color32 = Color32::from_rgba_premultiplied(230, 126, 34, 25);
pub const ACCENT_20: Color32 = Color32::from_rgba_premultiplied(230, 126, 34, 50);
pub const BORDER_ACCENT: Color32 = Color32::from_rgba_premultiplied(230, 126, 34, 60);
pub const SUCCESS: Color32 = Color32::from_rgb(80, 180, 90);
pub const WARNING: Color32 = Color32::from_rgb(220, 170, 60);
pub const ERROR: Color32 = Color32::from_rgb(210, 90, 80);
pub const INFO: Color32 = Color32::from_rgb(90, 155, 210);
pub const TEXT_PRIMARY: Color32 = Color32::from_rgb(224, 224, 224);
pub const TEXT_SECONDARY: Color32 = Color32::from_rgb(170, 170, 170);
pub const TEXT_MUTED: Color32 = Color32::from_rgb(136, 136, 136);
pub const TEXT_DISABLED: Color32 = Color32::from_rgb(90, 90, 90);
pub const TEXT_DEAD: Color32 = Color32::from_rgb(55, 55, 55);
pub const BORDER_SUBTLE: Color32 = Color32::from_rgb(42, 42, 42);
pub const BORDER_DEFAULT: Color32 = Color32::from_rgb(51, 51, 51);
pub const BORDER_STRONG: Color32 = Color32::from_rgb(64, 64, 64);
pub const BG_STRIP: Color32 = BG_SURFACE;
pub const BG_PANEL: Color32 = BG_SURFACE;
pub const BORDER: Color32 = BORDER_DEFAULT;
pub const BORDER_FAINT: Color32 = BORDER_SUBTLE;
pub const BORDER_MID: Color32 = BORDER_STRONG;
pub const WARN: Color32 = WARNING;
pub const DANGER: Color32 = ERROR;
pub const BLUE: Color32 = INFO;
pub const TEXT_GHOST: Color32 = TEXT_DISABLED;
pub const TEXT_DIM: Color32 = TEXT_MUTED;
pub const ACCENT_DIM: Color32 = Color32::from_rgb(160, 90, 25);
pub const TEXT_FAINT: Color32 = TEXT_DEAD;
pub const R_SM: CornerRadius = CornerRadius::same(6);
pub const R_MD: CornerRadius = CornerRadius::same(10);
pub const R_LG: CornerRadius = CornerRadius::same(14);
pub const R_FULL: CornerRadius = CornerRadius::same(255);
const NAME_PALETTE: [Color32; 6] = [
Color32::from_rgb(170, 170, 170),
Color32::from_rgb(106, 158, 207),
Color32::from_rgb(124, 184, 122),
Color32::from_rgb(220, 170, 60),
Color32::from_rgb(190, 140, 200),
Color32::from_rgb(120, 190, 180),
];
pub fn user_color(user_id: &str) -> Color32 {
if user_id.is_empty() {
return TEXT_SECONDARY;
}
let hash: usize = user_id
.bytes()
.fold(0usize, |acc, b| acc.wrapping_add(b as usize));
NAME_PALETTE[hash % NAME_PALETTE.len()]
}
pub fn apply_visuals(ctx: &egui::Context) {
let mut v = Visuals::dark();
v.window_fill = BG_BASE;
v.panel_fill = BG_BASE;
v.extreme_bg_color = BG_INTERACTIVE;
v.faint_bg_color = BG_ELEVATED;
v.code_bg_color = BG_SURFACE;
v.hyperlink_color = ACCENT;
v.override_text_color = Some(TEXT_PRIMARY);
v.widgets.noninteractive.bg_fill = BG_SURFACE;
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, BORDER_SUBTLE);
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, TEXT_MUTED);
v.widgets.noninteractive.corner_radius = R_MD;
v.widgets.inactive.bg_fill = Color32::TRANSPARENT;
v.widgets.inactive.bg_stroke = Stroke::new(1.0, BORDER_DEFAULT);
v.widgets.inactive.fg_stroke = Stroke::new(1.0, TEXT_MUTED);
v.widgets.inactive.corner_radius = R_MD;
v.widgets.hovered.bg_fill = BG_INTERACTIVE;
v.widgets.hovered.bg_stroke = Stroke::new(1.0, BORDER_STRONG);
v.widgets.hovered.fg_stroke = Stroke::new(1.0, TEXT_PRIMARY);
v.widgets.hovered.corner_radius = R_MD;
v.widgets.active.bg_fill = BG_INTERACTIVE;
v.widgets.active.bg_stroke = Stroke::new(1.0, BORDER_ACCENT);
v.widgets.active.fg_stroke = Stroke::new(1.0, TEXT_PRIMARY);
v.widgets.active.corner_radius = R_MD;
v.widgets.open.bg_fill = BG_INTERACTIVE;
v.widgets.open.bg_stroke = Stroke::new(1.0, BORDER_STRONG);
v.widgets.open.fg_stroke = Stroke::new(1.0, TEXT_PRIMARY);
v.widgets.open.corner_radius = R_MD;
v.selection.bg_fill = ACCENT_10;
v.selection.stroke = Stroke::new(0.0, Color32::TRANSPARENT);
v.window_corner_radius = R_LG;
v.window_shadow = egui::epaint::Shadow::NONE;
v.popup_shadow = egui::epaint::Shadow::NONE;
v.window_stroke = Stroke::new(1.0, BORDER_DEFAULT);
ctx.set_visuals(v);
set_fonts(ctx);
set_spacing(ctx);
}
fn set_fonts(ctx: &egui::Context) {
let fonts = FontDefinitions::default();
ctx.set_fonts(fonts);
}
fn set_spacing(ctx: &egui::Context) {
let mut s = (*ctx.style()).clone();
s.text_styles = [
(egui::TextStyle::Small, egui::FontId::proportional(11.0)),
(egui::TextStyle::Body, egui::FontId::proportional(13.0)),
(egui::TextStyle::Button, egui::FontId::proportional(13.0)),
(egui::TextStyle::Heading, egui::FontId::proportional(15.0)),
(egui::TextStyle::Monospace, egui::FontId::monospace(12.0)),
]
.into();
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
s.spacing.button_padding = egui::vec2(10.0, 5.0);
s.spacing.window_margin = egui::Margin::same(0);
s.spacing.menu_margin = egui::Margin::same(4);
s.spacing.indent = 16.0;
s.spacing.interact_size = egui::vec2(40.0, 28.0);
s.spacing.scroll.bar_width = 4.0;
ctx.set_style(s);
}

View file

@ -1,76 +0,0 @@
use eframe::egui::{self, Color32, Rect, Stroke};
use super::*;
pub fn avatar(ui: &mut egui::Ui, initials: &str, color: Color32, size: f32) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover());
if ui.is_rect_visible(rect) {
let painter = ui.painter();
painter.circle_filled(rect.center(), size / 2.0, color);
painter.circle_stroke(rect.center(), size / 2.0, Stroke::new(1.0, BORDER_DEFAULT));
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
initials,
egui::FontId::proportional(size * 0.38),
TEXT_PRIMARY,
);
}
resp
}
pub fn icon_btn(
ui: &mut egui::Ui,
icon: &str,
tooltip: &str,
active_color: Option<Color32>,
) -> egui::Response {
let color = active_color.unwrap_or(TEXT_MUTED);
let btn = egui::Button::new(egui::RichText::new(icon).color(color).size(14.0))
.frame(false)
.min_size(egui::vec2(26.0, 26.0));
let resp = ui.add(btn);
if !tooltip.is_empty() {
resp.clone().on_hover_text(tooltip);
}
resp
}
/// Draw a vector icon into a button-sized area.
/// `draw_fn` is one of the functions from `crate::theme::icons` (e.g. `icons::gear`).
/// The icon is rendered with the given color; pass `None` to use TEXT_MUTED.
pub fn icon_btn_drawn(
ui: &mut egui::Ui,
draw_fn: fn(&egui::Painter, Rect, Color32),
tooltip: &str,
active_color: Option<Color32>,
size: f32,
) -> egui::Response {
let color = active_color.unwrap_or(TEXT_MUTED);
let (rect, resp) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click());
if ui.is_rect_visible(rect) {
let painter = ui.painter();
// Inset to give padding.
let pad = size * 0.10;
let inner = Rect::from_min_max(
(rect.min.x + pad, rect.min.y + pad).into(),
(rect.max.x - pad, rect.max.y - pad).into(),
);
draw_fn(painter, inner, color);
}
if ui.is_rect_visible(rect) && resp.hovered() {
let painter = ui.painter();
painter.rect_filled(rect, egui::CornerRadius::same(4), BG_ELEVATED);
// Re-draw the icon on top of the hover background.
let pad = size * 0.10;
let inner = Rect::from_min_max(
(rect.min.x + pad, rect.min.y + pad).into(),
(rect.max.x - pad, rect.max.y - pad).into(),
);
draw_fn(painter, inner, color);
}
if !tooltip.is_empty() {
resp.clone().on_hover_text(tooltip);
}
resp
}

View file

@ -1,93 +0,0 @@
use eframe::egui::{self, Context, Frame, TopBottomPanel};
use crate::state::{ConnState, UiState};
use crate::theme as t;
pub const TITLEBAR_H: f32 = 30.0;
pub(crate) fn show_titlebar(ctx: &Context, s: &mut UiState) {
TopBottomPanel::top("titlebar")
.exact_height(TITLEBAR_H)
.frame(Frame::NONE.fill(t::BG_SURFACE))
.show_separator_line(false)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.add_space(12.0);
ui.with_layout(
egui::Layout::left_to_right(egui::Align::Center).with_main_justify(true),
|ui| {
let w = ui.available_width();
if w.is_finite() && w > 0.0 {
ui.set_width(w);
}
let title = match &s.conn {
ConnState::Connected { node_name, .. } => {
format!("VNOX · {node_name}")
}
ConnState::Connecting => "VNOX · connecting…".into(),
ConnState::Reconnecting { attempt } => {
format!("VNOX · reconnect #{attempt}")
}
ConnState::Disconnected => "VNOX · offline".into(),
};
ui.label(
egui::RichText::new(title)
.font(egui::FontId::monospace(11.0))
.color(t::TEXT_MUTED),
);
},
);
if matches!(s.conn, ConnState::Connected { .. })
&& s.overlay_latency
&& let Some(rtt) = s.rtt_ms
{
let color = if rtt < 80 {
t::SUCCESS
} else if rtt < 200 {
t::WARNING
} else {
t::ERROR
};
ui.label(
egui::RichText::new(format!("{rtt} ms"))
.font(egui::FontId::monospace(10.0))
.color(color),
);
ui.add_space(8.0);
}
let transport = match &s.conn {
ConnState::Connected { .. } => "quic/v1",
ConnState::Connecting => "connecting…",
ConnState::Reconnecting { .. } => "reconnecting…",
ConnState::Disconnected => "idle",
};
ui.label(
egui::RichText::new(transport)
.font(egui::FontId::monospace(10.0))
.color(t::TEXT_DISABLED),
);
ui.add_space(10.0);
let settings_color = if s.settings_open {
Some(t::ACCENT)
} else {
None
};
if t::icon_btn_drawn(
ui,
crate::theme::icons::gear,
"Settings",
settings_color,
18.0,
)
.clicked()
{
s.settings_open = !s.settings_open;
}
ui.add_space(12.0);
});
});
}

806
src/ui/update.rs Normal file
View file

@ -0,0 +1,806 @@
use std::collections::HashMap;
use vnox_client::audio::{IncomingVoice, PipelineConfig};
use vnox_client::net::{ChatMsg, NetCommand, NetEvent, NetHandle, PresenceInfo};
use crate::ui::state::{ChatMessage, ConnState, UiState, VoiceUiState};
use crate::AppState;
pub fn process_net_events(state: &mut AppState) {
while let Some(event) = state.net.try_recv() {
match event {
NetEvent::Connected {
session_id,
node_name,
restored,
private_mode,
} => {
state.ui.conn = ConnState::Connected {
session_id,
node_name: node_name.clone(),
};
state.ui.private_mode = private_mode;
if restored {
state.ui.status_msg = Some("reconnected".into());
if let Some(ch) = state.ui.active_channel.clone() {
let net = state.net.clone();
tokio::spawn(async move {
net.send(NetCommand::JoinChannel { channel_id: ch }).await;
});
}
} else {
state.ui.seed_default_channels();
state.ui.active_channel = Some("general".into());
state.ui.status_msg = None;
let net = state.net.clone();
tokio::spawn(async move {
net.send(NetCommand::JoinChannel {
channel_id: "general".into(),
})
.await;
});
}
}
NetEvent::Disconnected { reason, will_retry } => {
state.ui.voice = VoiceUiState::Off;
state.audio = None;
state.voice_channel = None;
state.last_pipeline_config = None;
if will_retry {
state.ui.status_msg = Some(format!("connection lost: {reason}"));
} else {
state.ui.conn = ConnState::Disconnected;
state.ui.channels.clear();
state.ui.active_channel = None;
state.ui.user_names.clear();
state.ui.rtt_ms = None;
state.ui.status_msg = Some(format!("disconnected: {reason}"));
}
state.ui.dm_conversations.clear();
state.ui.dm_messages.clear();
state.ui.active_dm_id = None;
state.ui.new_dm_input = None;
state.ui.private_mode = false;
}
NetEvent::Reconnecting {
attempt,
delay_secs,
} => {
state.ui.voice = VoiceUiState::Off;
state.audio = None;
state.voice_channel = None;
state.last_pipeline_config = None;
state.ui.conn = ConnState::Reconnecting { attempt };
state.ui.status_msg =
Some(format!("reconnecting in {delay_secs}s (attempt {attempt})"));
}
NetEvent::VoicePacket {
channel_id,
voice_seq,
timestamp,
opus_data,
sender_id,
..
} => {
if !sender_id.is_empty() {
state.ui.last_remote_speaker_id = sender_id.clone();
state.ui.last_voice_packet_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
}
if !state.ui.deafen
&& let Some(pipeline) = state.audio.as_ref()
{
pipeline.push_voice(IncomingVoice {
voice_seq,
timestamp,
channel_id,
opus_data,
});
}
}
NetEvent::E2eeDmKeyExchange {
dm_id,
e2ee_public_key,
} => {
let (secret, public) = vnox_client::e2ee::derive_e2ee_keypair(&state.identity);
let shared = vnox_client::e2ee::compute_shared_secret(&secret, &e2ee_public_key);
if let Some(conv) = state
.ui
.dm_conversations
.iter_mut()
.find(|c| c.dm_id == dm_id)
{
conv.e2ee_enabled = true;
conv.e2ee_peer_public_key = Some(e2ee_public_key);
conv.e2ee_shared_secret = Some(shared);
}
let pubkey_bytes = public.to_bytes().to_vec();
let net = state.net.clone();
tokio::spawn(async move {
net.send(NetCommand::E2eeDmKeyExchange {
dm_id,
e2ee_public_key: pubkey_bytes,
})
.await;
});
}
NetEvent::E2eeDmKeyExchangeAck { dm_id } => {
if let Some(conv) = state
.ui
.dm_conversations
.iter_mut()
.find(|c| c.dm_id == dm_id)
{
conv.e2ee_enabled = true;
}
}
NetEvent::E2eeDmMessage {
dm_id,
sender_id,
ciphertext,
timestamp,
} => {
let shared = state
.ui
.dm_conversations
.iter()
.find(|c| c.dm_id == dm_id)
.and_then(|c| c.e2ee_shared_secret);
let Some(shared) = shared else {
continue;
};
let msg_id = uuid::Uuid::new_v4().to_string();
let content = vnox_client::e2ee::decrypt_message(&shared, &msg_id, &ciphertext);
state
.ui
.dm_messages
.entry(dm_id.clone())
.or_default()
.push(ChatMessage {
message_id: msg_id,
sender_id,
content,
timestamp,
edited: false,
reactions: HashMap::new(),
reply_to: None,
});
if state.ui.active_dm_id.as_deref() != Some(&dm_id)
&& let Some(c) = state
.ui
.dm_conversations
.iter_mut()
.find(|c| c.dm_id == dm_id)
{
c.unread_count += 1;
}
}
NetEvent::E2eeDmHistory { dm_id, messages } => {
let shared = state
.ui
.dm_conversations
.iter()
.find(|c| c.dm_id == dm_id)
.and_then(|c| c.e2ee_shared_secret);
let Some(shared) = shared else {
continue;
};
let decrypted: Vec<ChatMessage> = messages
.into_iter()
.map(|m| {
let msg_id = uuid::Uuid::new_v4().to_string();
let content =
vnox_client::e2ee::decrypt_message(&shared, &msg_id, &m.ciphertext);
ChatMessage {
message_id: msg_id,
sender_id: m.sender_id,
content,
timestamp: m.timestamp,
edited: false,
reactions: HashMap::new(),
reply_to: None,
}
})
.collect();
state.ui.dm_messages.insert(dm_id, decrypted);
}
event => process_net_event(&mut state.ui, &state.net, event),
}
sync_voice_audio(state);
flush_encoded_voice(state);
}
if let Some(pipeline) = state.audio.as_ref() {
state.ui.local_speaking = pipeline.local_speaking();
state.ui.remote_speaking = pipeline.remote_speaking(500);
} else {
state.ui.local_speaking = false;
state.ui.remote_speaking = false;
}
if !state.ui.remote_speaking {
state.ui.last_remote_speaker_id.clear();
}
}
fn process_net_event(ui: &mut UiState, net: &NetHandle, event: NetEvent) {
match &event {
NetEvent::ChannelState { .. }
| NetEvent::ChannelCreated { .. }
| NetEvent::ChannelDeleted { .. }
| NetEvent::ChannelListEvent { .. }
| NetEvent::ChatMessage { .. }
| NetEvent::ChatHistory { .. }
| NetEvent::UserJoin { .. }
| NetEvent::UserLeave { .. }
| NetEvent::Error { .. }
| NetEvent::ReactionAdded { .. }
| NetEvent::ReactionRemoved { .. }
| NetEvent::MessageEdited { .. }
| NetEvent::MessageDeleted { .. }
| NetEvent::DmStart { .. }
| NetEvent::DmMessage { .. }
| NetEvent::DmHistory { .. }
| NetEvent::ReadReceiptBroadcast { .. }
| NetEvent::UserColor { .. } => chat_handle(ui, net, event),
NetEvent::GuildList { .. }
| NetEvent::GuildCreated { .. }
| NetEvent::GuildDeleted { .. }
| NetEvent::GuildMemberJoined { .. }
| NetEvent::GuildMemberLeft { .. }
| NetEvent::GuildMemberKicked { .. }
| NetEvent::InviteCreated { .. }
| NetEvent::InviteAccepted { .. }
| NetEvent::InviteDeleted { .. }
| NetEvent::RoleCreated { .. }
| NetEvent::RoleDeleted { .. }
| NetEvent::GuildAuditLog { .. }
| NetEvent::GuildMemberList { .. }
| NetEvent::GuildRoleList { .. }
| NetEvent::FriendList { .. }
| NetEvent::FriendRequested { .. }
| NetEvent::FriendAccepted { .. }
| NetEvent::FriendRemoved { .. }
| NetEvent::BlockList { .. }
| NetEvent::BlockedUser { .. }
| NetEvent::UnblockedUser { .. } => social_handle(ui, event),
NetEvent::LatencyUpdate { .. }
| NetEvent::TypingStart { .. }
| NetEvent::PresenceSync { .. }
| NetEvent::PresenceUpdated { .. } => status_handle(ui, net, event),
NetEvent::Connected { .. }
| NetEvent::Disconnected { .. }
| NetEvent::Reconnecting { .. }
| NetEvent::VoicePacket { .. }
| NetEvent::E2eeDmKeyExchange { .. }
| NetEvent::E2eeDmKeyExchangeAck { .. }
| NetEvent::E2eeDmMessage { .. }
| NetEvent::E2eeDmHistory { .. } => {}
}
}
fn into_msg(m: ChatMsg) -> ChatMessage {
ChatMessage {
message_id: m.message_id,
sender_id: m.sender_id,
content: m.content,
timestamp: m.timestamp,
edited: false,
reactions: HashMap::new(),
reply_to: m.reply_to,
}
}
fn parse_hex_color(hex: &str) -> Option<String> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return None;
}
u32::from_str_radix(hex, 16).ok()?;
Some(hex.to_string())
}
fn chat_handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) {
match event {
NetEvent::ChannelState {
channel_id,
channel_name,
kind,
members,
..
} => {
let mems: Vec<String> = members
.iter()
.map(|m| {
ui.user_names.insert(m.user_id.clone(), m.nickname.clone());
m.nickname.clone()
})
.collect();
if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id) {
ch.members = mems;
} else {
ui.channels.push(crate::ui::state::Channel {
id: channel_id.clone(),
name: channel_name,
kind: kind.clone(),
members: mems,
});
}
if ui.active_channel.is_none() {
ui.active_channel = Some(channel_id.clone());
}
let net = net.clone();
let ch_id = channel_id;
tokio::spawn(async move {
net.send(NetCommand::ReadReceipt {
channel_id: ch_id,
last_read_message_id: uuid::Uuid::new_v4().to_string(),
})
.await;
});
}
NetEvent::ChannelCreated {
channel_id,
channel_name,
kind,
} => {
if !ui.channels.iter().any(|c| c.id == channel_id) {
ui.channels.push(crate::ui::state::Channel {
id: channel_id,
name: channel_name,
kind,
members: Vec::new(),
});
}
}
NetEvent::ChannelDeleted { channel_id } => {
ui.channels.retain(|c| c.id != channel_id);
if ui.active_channel.as_deref() == Some(&channel_id) {
ui.active_channel = ui.channels.first().map(|c| c.id.clone());
}
ui.messages.remove(&channel_id);
}
NetEvent::ChannelListEvent { channels } => {
let old: std::collections::HashMap<String, crate::ui::state::Channel> =
std::mem::take(&mut ui.channels)
.into_iter()
.map(|c| (c.id.clone(), c))
.collect();
ui.channels = channels
.into_iter()
.map(|c| {
let members = old
.get(&c.channel_id)
.map(|o| o.members.clone())
.unwrap_or_default();
crate::ui::state::Channel {
id: c.channel_id,
name: c.channel_name,
kind: c.kind,
members,
}
})
.collect();
}
NetEvent::UserJoin {
channel_id,
user_id,
nickname,
..
} => {
ui.user_names.insert(user_id, nickname.clone());
if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id)
&& !ch.members.contains(&nickname)
{
ch.members.push(nickname);
}
}
NetEvent::UserLeave {
channel_id,
user_id,
} => {
if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id) {
let nickname = ui.user_names.remove(&user_id);
ch.members.retain(|m| {
m != &user_id && nickname.as_deref().map(|n| m != n).unwrap_or(true)
});
}
}
NetEvent::DmStart {
dm_id,
other_nickname,
messages,
unread_count,
..
} => {
if !ui.dm_conversations.iter().any(|c| c.dm_id == dm_id) {
ui.dm_conversations.push(crate::ui::state::DmConversation {
dm_id: dm_id.clone(),
other_nickname,
unread_count,
e2ee_enabled: false,
e2ee_peer_public_key: None,
e2ee_shared_secret: None,
});
}
ui.dm_messages
.insert(dm_id.clone(), messages.into_iter().map(into_msg).collect());
ui.active_dm_id = Some(dm_id);
}
NetEvent::ChatMessage {
message_id,
channel_id,
sender_id,
content,
timestamp,
reply_to,
..
} => {
ui.messages
.entry(channel_id)
.or_default()
.push(ChatMessage {
message_id,
sender_id,
content,
timestamp,
edited: false,
reactions: HashMap::new(),
reply_to,
});
ui.scroll_bottom = true;
}
NetEvent::ChatHistory {
channel_id,
messages,
} => {
ui.messages
.insert(channel_id, messages.into_iter().map(into_msg).collect());
ui.scroll_bottom = true;
}
NetEvent::Error { message, .. } => {
ui.status_msg = Some(format!("error: {message}"));
}
NetEvent::ReactionAdded {
channel_id,
message_id,
user_id,
emoji,
} => {
if let Some(msgs) = ui.messages.get_mut(&channel_id)
&& let Some(msg) = msgs.iter_mut().find(|m| m.message_id == message_id)
{
msg.reactions.entry(emoji).or_default().push(user_id);
}
}
NetEvent::ReactionRemoved {
channel_id,
message_id,
user_id,
emoji,
} => {
if let Some(msgs) = ui.messages.get_mut(&channel_id)
&& let Some(msg) = msgs.iter_mut().find(|m| m.message_id == message_id)
&& let Some(users) = msg.reactions.get_mut(&emoji)
{
users.retain(|u| u != &user_id);
if users.is_empty() {
msg.reactions.remove(&emoji);
}
}
}
NetEvent::MessageEdited {
channel_id,
message_id,
content,
..
} => {
if let Some(msgs) = ui.messages.get_mut(&channel_id)
&& let Some(msg) = msgs.iter_mut().find(|m| m.message_id == message_id)
{
msg.content = content;
msg.edited = true;
}
}
NetEvent::MessageDeleted {
channel_id,
message_id,
} => {
if let Some(msgs) = ui.messages.get_mut(&channel_id) {
msgs.retain(|m| m.message_id != message_id);
}
}
NetEvent::DmMessage {
dm_id,
sender_id,
content,
timestamp,
..
} => {
ui.dm_messages
.entry(dm_id.clone())
.or_default()
.push(ChatMessage {
message_id: uuid::Uuid::new_v4().to_string(),
sender_id,
content,
timestamp,
edited: false,
reactions: HashMap::new(),
reply_to: None,
});
if ui.active_dm_id.as_deref() != Some(&dm_id)
&& let Some(c) = ui.dm_conversations.iter_mut().find(|c| c.dm_id == dm_id)
{
c.unread_count += 1;
}
}
NetEvent::DmHistory { dm_id, messages } => {
ui.dm_messages
.insert(dm_id, messages.into_iter().map(into_msg).collect());
}
NetEvent::ReadReceiptBroadcast {
channel_id,
user_id,
last_read_message_id,
} => {
ui.read_receipts
.entry(channel_id)
.or_default()
.insert(user_id, last_read_message_id);
}
NetEvent::UserColor { user_id, color } => {
if let Some(c) = parse_hex_color(&color) {
ui.user_colors.insert(user_id, c);
}
}
_ => {}
}
}
fn social_handle(ui: &mut UiState, event: NetEvent) {
match event {
NetEvent::GuildAuditLog { .. } => {}
NetEvent::GuildMemberList { .. } => {}
NetEvent::GuildRoleList { .. } => {}
NetEvent::BlockList { blocked } => {
ui.blocked_users = blocked;
}
NetEvent::BlockedUser { user_id } => {
if !ui.blocked_users.contains(&user_id) {
ui.blocked_users.push(user_id);
}
}
NetEvent::UnblockedUser { user_id } => {
ui.blocked_users.retain(|u| u != &user_id);
}
NetEvent::GuildList { guilds } => {
ui.guilds = guilds
.into_iter()
.map(|g| crate::ui::state::GuildState {
guild_id: g.guild_id,
name: g.name,
member_count: g.member_count,
})
.collect();
}
NetEvent::GuildCreated {
guild_id,
name,
..
} => {
ui.guilds.push(crate::ui::state::GuildState {
guild_id: guild_id.clone(),
name,
member_count: 1,
});
ui.active_guild_id = Some(guild_id);
}
NetEvent::GuildDeleted { guild_id } => {
ui.guilds.retain(|g| g.guild_id != guild_id);
if ui.active_guild_id.as_deref() == Some(&guild_id) {
ui.active_guild_id = None;
}
}
NetEvent::GuildMemberJoined {
guild_id,
user_id: _,
nickname: _,
} => {
if let Some(g) = ui.guilds.iter_mut().find(|g| g.guild_id == guild_id) {
g.member_count += 1;
}
}
NetEvent::GuildMemberLeft {
guild_id,
user_id: _,
} => {
if let Some(g) = ui.guilds.iter_mut().find(|g| g.guild_id == guild_id) {
g.member_count = g.member_count.saturating_sub(1);
}
}
NetEvent::GuildMemberKicked {
guild_id,
user_id: _,
} => {
if let Some(g) = ui.guilds.iter_mut().find(|g| g.guild_id == guild_id) {
g.member_count = g.member_count.saturating_sub(1);
}
}
NetEvent::InviteCreated { .. } => {}
NetEvent::InviteAccepted {
guild_id,
guild_name,
} if !ui.guilds.iter().any(|g| g.guild_id == guild_id) => {
ui.guilds.push(crate::ui::state::GuildState {
guild_id,
name: guild_name,
member_count: 1,
});
}
NetEvent::InviteDeleted { .. } => {}
NetEvent::RoleCreated { .. } => {}
NetEvent::RoleDeleted { .. } => {}
NetEvent::FriendList { friends } => {
ui.friends = friends
.into_iter()
.map(|f| crate::ui::state::FriendState {
user_id: f.user_id,
nickname: f.nickname,
})
.collect();
}
NetEvent::FriendRequested { user_id, nickname } => {
ui.pending_friend_requests
.push(crate::ui::state::FriendState {
user_id,
nickname,
});
}
NetEvent::FriendAccepted { user_id, nickname } => {
ui.pending_friend_requests.retain(|f| f.user_id != user_id);
if !ui.friends.iter().any(|f| f.user_id == user_id) {
ui.friends.push(crate::ui::state::FriendState {
user_id,
nickname,
});
}
}
NetEvent::FriendRemoved { user_id } => {
ui.friends.retain(|f| f.user_id != user_id);
ui.pending_friend_requests.retain(|f| f.user_id != user_id);
}
_ => {}
}
}
fn status_handle(ui: &mut UiState, _net: &NetHandle, event: NetEvent) {
match event {
NetEvent::LatencyUpdate { rtt_ms } => {
ui.rtt_ms = Some(rtt_ms);
}
NetEvent::TypingStart {
user_id,
nickname,
channel_id,
} => {
let entry = ui.typing_users.entry(channel_id).or_default();
entry.retain(|(u, _)| u != &user_id);
entry.push((nickname, std::time::Instant::now()));
}
NetEvent::PresenceSync { presences } => {
for p in presences {
ui.presences.insert(p.user_id.clone(), p);
}
}
NetEvent::PresenceUpdated {
user_id,
status,
custom_status,
activity,
} => {
ui.presences.insert(
user_id.clone(),
PresenceInfo {
user_id,
status,
custom_status,
activity,
activity_text: None,
},
);
}
_ => {}
}
}
pub fn pipeline_config(channel_id: &str, state: &AppState) -> PipelineConfig {
PipelineConfig {
channel_key: vnox_client::net::voice::channel_key(channel_id),
mic_device_index: state.ui.mic_device,
output_device_index: state.ui.output_device,
voice_bitrate_kbps: state.ui.voice_bitrate,
packet_interval: state.ui.packet_interval,
}
}
pub fn start_voice(state: &mut AppState, channel_id: &str) {
let cfg = pipeline_config(channel_id, state);
if state.voice_channel.as_deref() == Some(channel_id)
&& state.audio.is_some()
&& state.last_pipeline_config.as_ref() == Some(&cfg)
{
return;
}
state.audio = None;
match vnox_client::audio::start(cfg.clone()) {
Ok(pipeline) => {
tracing::info!("voice active on channel {channel_id}");
state.audio = Some(pipeline);
state.voice_channel = Some(channel_id.to_string());
state.last_pipeline_config = Some(cfg);
state.ui.voice = VoiceUiState::Active {
channel: channel_id.to_string(),
};
if state.ui.voice_joined_at.is_none() {
state.ui.voice_joined_at = Some(std::time::Instant::now());
}
state.ui.status_msg = None;
}
Err(e) => {
tracing::warn!("audio start failed: {e}");
state.voice_channel = None;
state.last_pipeline_config = None;
state.ui.voice = VoiceUiState::Error(e.to_string());
state.ui.status_msg = Some(format!("microphone error: {e}"));
}
}
}
pub fn stop_voice(state: &mut AppState) {
state.audio = None;
state.voice_channel = None;
state.last_pipeline_config = None;
state.ui.voice = VoiceUiState::Off;
state.ui.voice_joined_at = None;
}
pub fn sync_voice_audio(state: &mut AppState) {
if !matches!(state.ui.conn, ConnState::Connected { .. }) {
stop_voice(state);
return;
}
let active_voice = state
.ui
.active_channel
.as_ref()
.and_then(|id| state.ui.channels.iter().find(|c| &c.id == id))
.filter(|c| c.kind == "voice")
.map(|c| c.id.clone());
match active_voice {
Some(id) => start_voice(state, &id),
None => stop_voice(state),
}
}
pub fn flush_encoded_voice(state: &mut AppState) {
let Some(pipeline) = state.audio.as_ref() else {
return;
};
while let Some(frame) = pipeline.poll_encoded() {
let net = state.net.clone();
tokio::spawn(async move {
net.send(NetCommand::SendVoice {
channel_id: frame.channel_id,
voice_seq: frame.voice_seq,
timestamp: frame.timestamp,
opus_data: frame.data,
})
.await;
});
}
}