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"
|
name = "vnox-e2e-voice"
|
||||||
path = "src/bin/e2e_voice.rs"
|
path = "src/bin/e2e_voice.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
slint-build = "1"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.52", features = ["full"] }
|
tokio = { version = "1.52", features = ["full"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
serde_bytes = "0.11"
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
|
|
@ -44,7 +48,7 @@ opus = "0.3"
|
||||||
cpal = "0.18"
|
cpal = "0.18"
|
||||||
rodio = "0.22"
|
rodio = "0.22"
|
||||||
dirs = "6"
|
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 }
|
nnnoiseless = { version = "0.5.2", default-features = false, optional = true }
|
||||||
|
|
||||||
[features]
|
[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 anyhow::Result;
|
||||||
use cpal::traits::{DeviceTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
|
Arc, Mutex,
|
||||||
atomic::{AtomicU64, Ordering},
|
atomic::{AtomicU64, Ordering},
|
||||||
mpsc::TryRecvError,
|
mpsc::TryRecvError,
|
||||||
Arc, Mutex,
|
|
||||||
};
|
};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::audio::config::{self, AudioConfig, OPUS_RATE};
|
|
||||||
use crate::audio::IncomingVoice;
|
use crate::audio::IncomingVoice;
|
||||||
|
use crate::audio::config::{self, AudioConfig, OPUS_RATE};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct JitterConfig {
|
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 audio;
|
||||||
|
pub mod e2ee;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod identity_vault;
|
pub mod identity_vault;
|
||||||
pub mod jitter;
|
pub mod jitter;
|
||||||
|
|
|
||||||
343
src/main.rs
343
src/main.rs
|
|
@ -1,17 +1,36 @@
|
||||||
mod ui;
|
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 anyhow::Result;
|
||||||
|
use slint::ComponentHandle;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::EnvFilter;
|
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<()> {
|
fn main() -> Result<()> {
|
||||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
let filter = EnvFilter::try_from_default_env()
|
||||||
EnvFilter::new("info,wgpu=warn,wgpu_hal=warn,egui_wgpu=warn,naga=warn")
|
.unwrap_or_else(|_| EnvFilter::new("info,wgpu=warn,wgpu_hal=warn,naga=warn"));
|
||||||
});
|
|
||||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||||
|
|
||||||
let identity = identity::load_or_generate()?;
|
let identity = identity::load_or_generate()?;
|
||||||
|
|
@ -24,16 +43,306 @@ fn main() -> Result<()> {
|
||||||
|
|
||||||
let net = net::spawn(identity.clone());
|
let net = net::spawn(identity.clone());
|
||||||
|
|
||||||
eframe::run_native(
|
let state = Rc::new(RefCell::new(AppState {
|
||||||
"Vnox",
|
identity,
|
||||||
eframe::NativeOptions {
|
net,
|
||||||
viewport: eframe::egui::ViewportBuilder::default()
|
ui: UiState::default(),
|
||||||
.with_title("Vnox")
|
audio: None,
|
||||||
.with_inner_size([900.0, 560.0])
|
voice_channel: None,
|
||||||
.with_min_inner_size([600.0, 400.0]),
|
last_pipeline_config: None,
|
||||||
..Default::default()
|
}));
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Box::new(|cc| Ok(Box::new(ui::VnoxApp::new(cc, identity, net)))),
|
);
|
||||||
)
|
}
|
||||||
.map_err(|e| anyhow::anyhow!("eframe: {e}"))
|
|
||||||
|
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),
|
_ => 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(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,11 @@ pub async fn incoming(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) ->
|
||||||
| PID_MESSAGE_REACTION_REMOVE
|
| PID_MESSAGE_REACTION_REMOVE
|
||||||
| PID_MESSAGE_EDIT
|
| PID_MESSAGE_EDIT
|
||||||
| PID_MESSAGE_DELETE
|
| 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_LIST
|
||||||
| PID_GUILD_CREATE
|
| PID_GUILD_CREATE
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,34 @@
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct DmStartPayload {
|
pub struct DmStartPayload {
|
||||||
pub target_user_id: String,
|
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: u16 = 0x0064;
|
||||||
pub const PID_READ_RECEIPT_BROADCAST: u16 = 0x0065;
|
pub const PID_READ_RECEIPT_BROADCAST: u16 = 0x0065;
|
||||||
pub const PID_TYPING_START: u16 = 0x0070;
|
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_CREATE: u16 = 0x0100;
|
||||||
pub const PID_GUILD_DELETE: u16 = 0x0101;
|
pub const PID_GUILD_DELETE: u16 = 0x0101;
|
||||||
pub const PID_GUILD_LIST: u16 = 0x0102;
|
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?;
|
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(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -158,5 +158,20 @@ pub enum NetCommand {
|
||||||
channel_id: String,
|
channel_id: String,
|
||||||
message_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,
|
Disconnect,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -224,4 +224,21 @@ pub enum NetEvent {
|
||||||
channel_id: String,
|
channel_id: String,
|
||||||
message_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 bridge;
|
||||||
pub mod chat;
|
pub mod slint;
|
||||||
pub mod connect;
|
|
||||||
pub mod keybind;
|
|
||||||
pub mod members;
|
|
||||||
pub mod settings;
|
|
||||||
pub mod sidebar;
|
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod theme;
|
pub mod update;
|
||||||
pub mod titlebar;
|
|
||||||
|
|
||||||
pub use app::VnoxApp;
|
|
||||||
|
|
|
||||||
|
|
@ -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::collections::HashMap;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use super::models::{default_bookmarks, now_utc_hms};
|
use super::models::default_bookmarks;
|
||||||
use super::types::{ConnState, UiState, VoiceUiState};
|
use super::types::{ConnState, UiState, VoiceUiState};
|
||||||
|
|
||||||
impl Default for UiState {
|
impl Default for UiState {
|
||||||
|
|
@ -16,131 +15,42 @@ impl Default for UiState {
|
||||||
active_channel: None,
|
active_channel: None,
|
||||||
messages: Default::default(),
|
messages: Default::default(),
|
||||||
chat_input: String::new(),
|
chat_input: String::new(),
|
||||||
editing_message_id: None,
|
|
||||||
replying_to: None,
|
|
||||||
context_menu_message_id: None,
|
|
||||||
scroll_bottom: false,
|
scroll_bottom: false,
|
||||||
voice: VoiceUiState::Off,
|
voice: VoiceUiState::Off,
|
||||||
voice_joined_at: None,
|
voice_joined_at: None,
|
||||||
user_names: Default::default(),
|
user_names: Default::default(),
|
||||||
server_channel: None,
|
|
||||||
rtt_ms: None,
|
rtt_ms: None,
|
||||||
|
|
||||||
settings_open: false,
|
settings_open: false,
|
||||||
settings_page: Default::default(),
|
|
||||||
|
|
||||||
mic_enabled: true,
|
mic_enabled: true,
|
||||||
deafen: false,
|
deafen: false,
|
||||||
noise_suppress: true,
|
|
||||||
echo_cancel: true,
|
|
||||||
vad_mode: 0,
|
vad_mode: 0,
|
||||||
vad_threshold: 40.0,
|
|
||||||
voice_bitrate: 64.0,
|
voice_bitrate: 64.0,
|
||||||
packet_interval: 1,
|
packet_interval: 1,
|
||||||
mic_device: 0,
|
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_device: 0,
|
||||||
output_volume: 90.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,
|
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(),
|
guilds: Vec::new(),
|
||||||
active_guild_id: None,
|
active_guild_id: None,
|
||||||
presences: Default::default(),
|
presences: Default::default(),
|
||||||
presence_status: Some("online".into()),
|
|
||||||
custom_status: None,
|
|
||||||
activity_type: None,
|
|
||||||
activity_text: None,
|
|
||||||
friends: Vec::new(),
|
friends: Vec::new(),
|
||||||
pending_friend_requests: Vec::new(),
|
pending_friend_requests: Vec::new(),
|
||||||
blocked_users: Vec::new(),
|
blocked_users: Vec::new(),
|
||||||
block_input: None,
|
|
||||||
block_list_fetched_at: None,
|
|
||||||
dm_conversations: Vec::new(),
|
dm_conversations: Vec::new(),
|
||||||
dm_messages: Default::default(),
|
dm_messages: Default::default(),
|
||||||
active_dm_id: None,
|
active_dm_id: None,
|
||||||
new_dm_input: 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(),
|
user_colors: HashMap::new(),
|
||||||
per_user_volumes: Default::default(),
|
|
||||||
kb_listening: None,
|
|
||||||
|
|
||||||
private_mode: false,
|
private_mode: false,
|
||||||
|
|
||||||
collapsed_categories: HashSet::new(),
|
|
||||||
startup_time_str: now_utc_hms(),
|
|
||||||
typing_users: HashMap::new(),
|
typing_users: HashMap::new(),
|
||||||
last_typing_send: Instant::now(),
|
|
||||||
read_receipts: HashMap::new(),
|
read_receipts: HashMap::new(),
|
||||||
friends_tab: "all".into(),
|
friends_tab: "all".into(),
|
||||||
local_speaking: false,
|
local_speaking: false,
|
||||||
|
|
|
||||||
|
|
@ -2,57 +2,12 @@ mod default;
|
||||||
mod models;
|
mod models;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
pub use models::{
|
pub use models::{Channel, ChatMessage, DmConversation, FriendState, GuildState, NodeBookmark};
|
||||||
Channel, ChatMessage, DmConversation, FriendState, GuildState, NodeBookmark, SettingsPage,
|
pub use types::{ConnState, UiState, VoiceUiState};
|
||||||
};
|
|
||||||
pub use types::{ConnState, TYPING_TIMEOUT, UiState, VoiceUiState};
|
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use models::bookmark_label_from_address;
|
use models::bookmark_label_from_address;
|
||||||
|
|
||||||
impl UiState {
|
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) {
|
pub fn remove_bookmark(&mut self, index: usize) {
|
||||||
if index < self.bookmarks.len() {
|
if index < self.bookmarks.len() {
|
||||||
self.bookmarks.remove(index);
|
self.bookmarks.remove(index);
|
||||||
|
|
@ -73,11 +28,6 @@ impl UiState {
|
||||||
true
|
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 {
|
pub fn nick_for<'a>(&'a self, sender_id: &'a str) -> &'a str {
|
||||||
self.user_names
|
self.user_names
|
||||||
.get(sender_id)
|
.get(sender_id)
|
||||||
|
|
@ -85,12 +35,6 @@ impl UiState {
|
||||||
.unwrap_or(sender_id)
|
.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) {
|
pub fn seed_default_channels(&mut self) {
|
||||||
if !self.channels.is_empty() {
|
if !self.channels.is_empty() {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,5 @@
|
||||||
use std::collections::HashMap;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Channel {
|
pub struct Channel {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -38,16 +24,17 @@ pub struct ChatMessage {
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DmConversation {
|
pub struct DmConversation {
|
||||||
pub dm_id: String,
|
pub dm_id: String,
|
||||||
pub other_user_id: String,
|
|
||||||
pub other_nickname: String,
|
pub other_nickname: String,
|
||||||
pub unread_count: u32,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GuildState {
|
pub struct GuildState {
|
||||||
pub guild_id: String,
|
pub guild_id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub owner_id: String,
|
|
||||||
pub member_count: u32,
|
pub member_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,8 +42,6 @@ pub struct GuildState {
|
||||||
pub struct FriendState {
|
pub struct FriendState {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
pub nickname: String,
|
pub nickname: String,
|
||||||
pub status: String,
|
|
||||||
pub since: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -79,14 +64,3 @@ pub(crate) fn bookmark_label_from_address(address: &str) -> String {
|
||||||
.take(12)
|
.take(12)
|
||||||
.collect()
|
.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::collections::HashMap;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Instant;
|
||||||
|
|
||||||
pub const TYPING_TIMEOUT: Duration = Duration::from_secs(4);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Default)]
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
pub enum VoiceUiState {
|
pub enum VoiceUiState {
|
||||||
|
|
@ -37,169 +35,54 @@ pub struct UiState {
|
||||||
pub active_channel: Option<String>,
|
pub active_channel: Option<String>,
|
||||||
pub messages: HashMap<String, Vec<super::ChatMessage>>,
|
pub messages: HashMap<String, Vec<super::ChatMessage>>,
|
||||||
pub chat_input: String,
|
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 scroll_bottom: bool,
|
||||||
|
|
||||||
pub voice: VoiceUiState,
|
pub voice: VoiceUiState,
|
||||||
pub voice_joined_at: Option<Instant>,
|
pub voice_joined_at: Option<Instant>,
|
||||||
|
|
||||||
pub user_names: HashMap<String, String>,
|
pub user_names: HashMap<String, String>,
|
||||||
pub server_channel: Option<String>,
|
|
||||||
pub rtt_ms: Option<u32>,
|
pub rtt_ms: Option<u32>,
|
||||||
|
|
||||||
pub settings_open: bool,
|
pub settings_open: bool,
|
||||||
pub settings_page: super::SettingsPage,
|
|
||||||
|
|
||||||
pub mic_enabled: bool,
|
pub mic_enabled: bool,
|
||||||
pub deafen: bool,
|
pub deafen: bool,
|
||||||
pub noise_suppress: bool,
|
|
||||||
pub echo_cancel: bool,
|
|
||||||
pub vad_mode: usize,
|
pub vad_mode: usize,
|
||||||
pub vad_threshold: f32,
|
|
||||||
pub voice_bitrate: f32,
|
pub voice_bitrate: f32,
|
||||||
pub packet_interval: usize,
|
pub packet_interval: usize,
|
||||||
pub mic_device: 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_device: usize,
|
||||||
pub output_volume: f32,
|
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 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 private_mode: bool,
|
||||||
|
|
||||||
pub guilds: Vec<super::GuildState>,
|
pub guilds: Vec<super::GuildState>,
|
||||||
pub active_guild_id: Option<String>,
|
pub active_guild_id: Option<String>,
|
||||||
|
|
||||||
pub presences: HashMap<String, crate::net::PresenceInfo>,
|
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 friends: Vec<super::FriendState>,
|
||||||
pub pending_friend_requests: Vec<super::FriendState>,
|
pub pending_friend_requests: Vec<super::FriendState>,
|
||||||
/// Blocked user IDs.
|
|
||||||
pub blocked_users: Vec<String>,
|
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_conversations: Vec<super::DmConversation>,
|
||||||
pub dm_messages: HashMap<String, Vec<super::ChatMessage>>,
|
pub dm_messages: HashMap<String, Vec<super::ChatMessage>>,
|
||||||
pub active_dm_id: Option<String>,
|
pub active_dm_id: Option<String>,
|
||||||
pub new_dm_input: Option<String>,
|
pub new_dm_input: Option<String>,
|
||||||
pub dm_search_query: String,
|
|
||||||
|
|
||||||
pub user_colors: HashMap<String, eframe::egui::Color32>,
|
pub user_colors: HashMap<String, String>,
|
||||||
|
|
||||||
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 typing_users: HashMap<String, Vec<(String, Instant)>>,
|
pub typing_users: HashMap<String, Vec<(String, Instant)>>,
|
||||||
pub last_typing_send: Instant,
|
|
||||||
|
|
||||||
pub read_receipts: HashMap<String, HashMap<String, String>>,
|
pub read_receipts: HashMap<String, HashMap<String, String>>,
|
||||||
|
|
||||||
/// Active tab in the Friends panel ("online" / "all" / "pending" / "blocked").
|
|
||||||
pub friends_tab: String,
|
pub friends_tab: String,
|
||||||
|
|
||||||
/// True when local user is transmitting voice (PTT held or VAD above threshold).
|
|
||||||
pub local_speaking: bool,
|
pub local_speaking: bool,
|
||||||
/// True when a remote peer's voice packet arrived in the last ~500ms.
|
|
||||||
pub remote_speaking: bool,
|
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,
|
pub last_remote_speaker_id: String,
|
||||||
/// UNIX millisecond timestamp of the last voice packet from any remote peer.
|
|
||||||
pub last_voice_packet_ms: u64,
|
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