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:
parent
f361b037cb
commit
03d94e0465
83 changed files with 5055 additions and 9202 deletions
3491
Cargo.lock
generated
3491
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
3
build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
slint_build::compile("src/ui/slint/main.slint").unwrap();
|
||||
}
|
||||
|
|
@ -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
43
src/e2ee.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod audio;
|
||||
pub mod e2ee;
|
||||
pub mod identity;
|
||||
pub mod identity_vault;
|
||||
pub mod jitter;
|
||||
|
|
|
|||
345
src/main.rs
345
src/main.rs
|
|
@ -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 || {});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { .. } => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
267
src/ui/bridge.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()]
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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()]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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()]
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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()]
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
pub mod dm;
|
||||
pub mod header;
|
||||
pub mod userbar;
|
||||
|
|
@ -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
123
src/ui/slint/chat.slint
Normal 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
101
src/ui/slint/connect.slint
Normal 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
123
src/ui/slint/friends.slint
Normal 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
147
src/ui/slint/main.slint
Normal 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
1
src/ui/slint/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
::slint::include_modules!();
|
||||
68
src/ui/slint/settings.slint
Normal file
68
src/ui/slint/settings.slint
Normal 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(); } }
|
||||
}
|
||||
}
|
||||
92
src/ui/slint/sidebar.slint
Normal file
92
src/ui/slint/sidebar.slint
Normal 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
15
src/ui/slint/theme.slint
Normal 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
99
src/ui/slint/voice.slint
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
806
src/ui/update.rs
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue