Initial commit: split VNOX monorepo into VNOX-Client

Desktop client with egui + wgpu UI, audio pipeline (cpal/opus/rodio),
networking (LNEx protocol), and identity management with encrypted vault.

Jitter buffer module extracted from voice-node for standalone operation.
This commit is contained in:
loki5512344 2026-07-09 11:52:57 +02:00
commit bf0b237afd
112 changed files with 19113 additions and 0 deletions

29
.cargo/config.toml Normal file
View file

@ -0,0 +1,29 @@
[env]
# audiopus_sys builds opus from source via CMake.
# CMake 4.x removed compatibility with cmake_minimum_required < 3.5.
# This flag tells CMake to accept the old policy version.
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
# Cap linker+rustc memory pressure so builds don't OOM-kill the host.
RUST_MIN_STACK = "8388608"
[build]
# Limit parallelism so GPUI's giant dep tree doesn't saturate all cores.
# 4 jobs is a safe-ish ceiling for a 15GB / 24-thread box.
jobs = 6
[profile.dev]
# Less debug info = less RAM per codegen unit, faster cold builds.
debug = 1
# More codegen units = smaller per-unit memory footprint.
codegen-units = 16
# Don't keep intermediate artifacts for every dep.
split-debuginfo = "unpacked"
[profile.dev.package."*"]
# Deps don't need debuginfo at all → big savings on big trees like GPUI.
debug = 0
codegen-units = 16
[profile.release]
debug = 0
codegen-units = 16

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
target/
.DS_Store

5587
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

52
Cargo.toml Normal file
View file

@ -0,0 +1,52 @@
[workspace]
resolver = "2"
[package]
name = "vnox-client"
description = "VNOX desktop client — egui + wgpu"
version = "0.1.0"
edition = "2024"
license = "GPL-3.0"
authors = ["VNOX Contributors"]
repository = "https://github.com/loki5512344/Vnox"
rust-version = "1.85"
default-run = "vnox-client"
[lib]
name = "vnox_client"
path = "src/lib.rs"
[[bin]]
name = "vnox-client"
path = "src/main.rs"
[[bin]]
name = "vnox-e2e-voice"
path = "src/bin/e2e_voice.rs"
[dependencies]
tokio = { version = "1.52", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ed25519-dalek = { version = "2", features = ["rand_core"] }
x25519-dalek = { version = "2", features = ["static_secrets"] }
chacha20poly1305 = "0.10"
hkdf = "0.13"
sha2 = "0.11"
rand = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
hex = "0.4"
uuid = { version = "1", features = ["v4"] }
argon2 = "0.5"
opus = "0.3"
cpal = "0.18"
rodio = "0.22"
dirs = "6"
eframe = { version = "0.33", features = ["wgpu"] }
nnnoiseless = { version = "0.5.2", default-features = false, optional = true }
[features]
default = []
rnnoise = ["dep:nnnoiseless"]

141
src/audio/config.rs Normal file
View file

@ -0,0 +1,141 @@
use anyhow::{Result, anyhow};
use cpal::traits::DeviceTrait;
use cpal::{SampleFormat, StreamConfig};
use tracing::info;
pub const OPUS_RATE: u32 = 48_000;
pub const FRAME_MS: u32 = 20;
/// Maps settings `packet_interval` index to frame duration in ms.
pub fn frame_ms_from_interval(interval: usize) -> u32 {
match interval {
0 => 10,
1 => 20,
_ => 40,
}
}
/// Maps settings `jitter_size` index to playout delay in ms.
pub fn jitter_ms_from_setting(jitter_size: usize) -> u32 {
match jitter_size {
0 => 20,
1 => 40,
_ => 80,
}
}
pub fn bitrate_bps_from_kbps(kbps: f32) -> u32 {
(kbps.clamp(8.0, 512.0) * 1000.0) as u32
}
pub struct AudioConfig {
pub stream: StreamConfig,
pub sample_rate: u32,
}
impl AudioConfig {
pub fn find(device: &cpal::Device, is_input: bool) -> Result<Self> {
let ranges: Vec<_> = if is_input {
device.supported_input_configs()?.collect()
} else {
device.supported_output_configs()?.collect()
};
// Prefer 48 kHz mono F32 — matches Opus native rate.
for r in &ranges {
if r.channels() == 1
&& r.sample_format() == SampleFormat::F32
&& r.min_sample_rate() <= OPUS_RATE
&& r.max_sample_rate() >= OPUS_RATE
{
let stream = r.with_sample_rate(OPUS_RATE).config();
info!(
"audio {}: {} Hz mono",
if is_input { "in" } else { "out" },
OPUS_RATE
);
return Ok(Self {
stream,
sample_rate: OPUS_RATE,
});
}
}
// Fallback: device native rate, resample to/from Opus in capture/playback.
let default = if is_input {
device.default_input_config()
} else {
device.default_output_config()
}
.map_err(|e| anyhow!("no default audio config: {e}"))?;
let sample_rate = default.sample_rate();
let stream: StreamConfig = default.config();
info!(
"audio {}: {} Hz {}ch (will resample for Opus)",
if is_input { "in" } else { "out" },
sample_rate,
stream.channels
);
Ok(Self {
stream,
sample_rate,
})
}
}
pub fn frame_samples(sample_rate: u32) -> usize {
(sample_rate as usize * FRAME_MS as usize) / 1000
}
/// Linear resample mono F32 buffer to target sample count.
pub fn resample_linear(input: &[f32], out_len: usize) -> Vec<f32> {
if input.is_empty() {
return vec![0.0; out_len];
}
if input.len() == out_len {
return input.to_vec();
}
let mut out = Vec::with_capacity(out_len);
for i in 0..out_len {
let src = i as f64 * (input.len() - 1) as f64 / (out_len - 1).max(1) as f64;
let lo = src.floor() as usize;
let hi = (lo + 1).min(input.len() - 1);
let t = src - lo as f64;
out.push(input[lo] * (1.0 - t) as f32 + input[hi] * t as f32);
}
out
}
/// Resample between two sample rates (mono).
pub fn resample_rate(input: &[f32], from_hz: u32, to_hz: u32) -> Vec<f32> {
if from_hz == to_hz {
return input.to_vec();
}
let out_len = ((input.len() as u64 * to_hz as u64) / from_hz as u64) as usize;
resample_linear(input, out_len.max(1))
}
#[cfg(test)]
mod tests {
use super::{bitrate_bps_from_kbps, frame_ms_from_interval, jitter_ms_from_setting};
#[test]
fn jitter_size_maps_to_ms() {
assert_eq!(jitter_ms_from_setting(0), 20);
assert_eq!(jitter_ms_from_setting(1), 40);
assert_eq!(jitter_ms_from_setting(2), 80);
}
#[test]
fn packet_interval_maps_to_frame_ms() {
assert_eq!(frame_ms_from_interval(0), 10);
assert_eq!(frame_ms_from_interval(1), 20);
assert_eq!(frame_ms_from_interval(2), 40);
}
#[test]
fn bitrate_kbps_to_bps() {
assert_eq!(bitrate_bps_from_kbps(64.0), 64_000);
}
}

View file

@ -0,0 +1,42 @@
use anyhow::{Result, anyhow};
use cpal::Device;
use cpal::traits::{DeviceTrait, HostTrait};
pub fn input_at(index: usize) -> Result<Device> {
let host = cpal::default_host();
if index == 0 {
return host
.default_input_device()
.ok_or_else(|| anyhow!("no default input device"));
}
let devices: Vec<Device> = host.input_devices()?.collect();
let mut names: Vec<String> = Vec::new();
for dev in &devices {
if let Ok(name) = dev.description().map(|d| d.name().to_string())
&& !name.is_empty()
&& !names.contains(&name)
{
names.push(name);
}
}
let name = names
.get(index.saturating_sub(1))
.ok_or_else(|| anyhow!("input device index {index} out of range"))?;
for dev in devices {
if dev
.description()
.map(|d| d.name().to_string())
.ok()
.as_deref()
== Some(name.as_str())
{
return Ok(dev);
}
}
Err(anyhow!("input device not found: {name}"))
}

120
src/audio/devices/mod.rs Normal file
View file

@ -0,0 +1,120 @@
mod input;
mod output;
pub use input::input_at;
pub use output::output_at;
use anyhow::Result;
use cpal::traits::{DeviceTrait, HostTrait};
const DEFAULT_LABEL: &str = "Default";
#[derive(Debug, Clone)]
pub struct DeviceLabel {
pub display: String,
pub raw_name: String,
pub host_api: String,
pub channels: u16,
}
#[derive(Debug, Default)]
pub struct DeviceLists {
pub input_labels: Vec<String>,
pub output_labels: Vec<String>,
pub input_devices: Vec<DeviceLabel>,
pub output_devices: Vec<DeviceLabel>,
}
pub fn enumerate() -> Result<DeviceLists> {
let host = cpal::default_host();
let host_api = host.id().name().to_string();
let (input_labels, input_devices) = list_labels(host.input_devices()?, &host_api);
let (output_labels, output_devices) = list_labels(host.output_devices()?, &host_api);
Ok(DeviceLists {
input_labels,
output_labels,
input_devices,
output_devices,
})
}
fn list_labels(
devices: impl Iterator<Item = cpal::Device>,
host_api: &str,
) -> (Vec<String>, Vec<DeviceLabel>) {
let mut labels = vec![DEFAULT_LABEL.into()];
let mut metadatas = vec![DeviceLabel {
display: DEFAULT_LABEL.into(),
raw_name: String::new(),
host_api: host_api.into(),
channels: 0,
}];
for dev in devices {
let Ok(name) = dev.description().map(|d| d.name().to_string()) else {
continue;
};
if name.is_empty() {
continue;
}
let channels = supported_channel_count(&dev);
let display = format!("{} ({})", name, host_api);
if metadatas.iter().any(|m| m.raw_name == name) {
continue;
}
labels.push(display.clone());
metadatas.push(DeviceLabel {
display,
raw_name: name,
host_api: host_api.into(),
channels,
});
}
(labels, metadatas)
}
fn supported_channel_count(dev: &cpal::Device) -> u16 {
if let Ok(cfg) = dev.default_input_config() {
return cfg.channels();
}
if let Ok(cfg) = dev.default_output_config() {
return cfg.channels();
}
if let Ok(configs) = dev.supported_input_configs()
&& let Some(cfg) = configs.into_iter().next()
{
return cfg.channels();
}
if let Ok(configs) = dev.supported_output_configs()
&& let Some(cfg) = configs.into_iter().next()
{
return cfg.channels();
}
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_first_label() {
let lists = enumerate().unwrap_or_default();
if !lists.input_labels.is_empty() {
assert_eq!(lists.input_labels[0], "Default");
}
if !lists.output_labels.is_empty() {
assert_eq!(lists.output_labels[0], "Default");
}
}
#[test]
fn default_meta_is_sentinel() {
let lists = enumerate().unwrap_or_default();
assert_eq!(
lists.input_devices.first().map(|m| m.raw_name.as_str()),
Some("")
);
assert_eq!(
lists.output_devices.first().map(|m| m.raw_name.as_str()),
Some("")
);
}
}

View file

@ -0,0 +1,42 @@
use anyhow::{Result, anyhow};
use cpal::Device;
use cpal::traits::{DeviceTrait, HostTrait};
pub fn output_at(index: usize) -> Result<Device> {
let host = cpal::default_host();
if index == 0 {
return host
.default_output_device()
.ok_or_else(|| anyhow!("no default output device"));
}
let devices: Vec<Device> = host.output_devices()?.collect();
let mut names: Vec<String> = Vec::new();
for dev in &devices {
if let Ok(name) = dev.description().map(|d| d.name().to_string())
&& !name.is_empty()
&& !names.contains(&name)
{
names.push(name);
}
}
let name = names
.get(index.saturating_sub(1))
.ok_or_else(|| anyhow!("output device index {index} out of range"))?;
for dev in devices {
if dev
.description()
.map(|d| d.name().to_string())
.ok()
.as_deref()
== Some(name.as_str())
{
return Ok(dev);
}
}
Err(anyhow!("output device not found: {name}"))
}

60
src/audio/mod.rs Normal file
View file

@ -0,0 +1,60 @@
pub mod config;
pub mod devices;
mod pipeline;
mod processing;
pub use devices::{DeviceLabel, DeviceLists};
pub use pipeline::AudioPipeline;
pub use pipeline::JitterConfig;
pub use processing::{DenoiseState, TransmitGate};
#[derive(Debug, Clone, Copy)]
pub struct AudioSync {
pub mic_enabled: bool,
pub vad_mode: usize,
pub vad_threshold: f32,
pub input_volume: f32,
pub ptt_held: bool,
pub output_volume: f32,
pub jitter_size: usize,
pub adaptive_buffer: bool,
pub voice_bitrate_kbps: f32,
pub noise_suppress: bool,
}
#[derive(Debug, Clone)]
pub struct PipelineConfig {
pub channel_key: u64,
pub mic_device_index: usize,
pub output_device_index: usize,
pub voice_bitrate_kbps: f32,
pub packet_interval: usize,
}
impl PartialEq for PipelineConfig {
fn eq(&self, other: &Self) -> bool {
self.channel_key == other.channel_key
&& self.mic_device_index == other.mic_device_index
&& self.output_device_index == other.output_device_index
&& self.packet_interval == other.packet_interval
}
}
#[derive(Debug, Clone)]
pub struct IncomingVoice {
pub voice_seq: u32,
pub timestamp: u32,
pub channel_id: u64,
pub opus_data: Vec<u8>,
}
pub struct EncodedFrame {
pub channel_id: u64,
pub voice_seq: u32,
pub timestamp: u32,
pub data: Vec<u8>,
}
pub fn start(cfg: PipelineConfig) -> anyhow::Result<AudioPipeline> {
pipeline::start(cfg)
}

View file

@ -0,0 +1,148 @@
use anyhow::Result;
use cpal::traits::{DeviceTrait, StreamTrait};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use tracing::{info, warn};
use crate::audio::{
EncodedFrame,
config::{self, AudioConfig, OPUS_RATE},
processing::{DenoiseState, TransmitGate},
};
const MAX_OPUS: usize = 4000;
#[allow(clippy::too_many_arguments)]
pub fn run(
device: cpal::Device,
cfg: AudioConfig,
channel_id: u64,
tx: std::sync::mpsc::Sender<EncodedFrame>,
gate: Arc<Mutex<TransmitGate>>,
frame_ms: u32,
bitrate_bps: Arc<AtomicU32>,
local_speaking: Arc<AtomicBool>,
) -> Result<()> {
let mut encoder = opus::Encoder::new(OPUS_RATE, opus::Channels::Mono, opus::Application::Voip)?;
let mut last_bitrate_bps: u32 = 0;
apply_bitrate(&mut encoder, &bitrate_bps, &mut last_bitrate_bps)?;
let mut denoise = DenoiseState::new()?;
let capture_frame = frame_samples_at(cfg.sample_rate, frame_ms);
let opus_frame = frame_samples_at(OPUS_RATE, frame_ms);
let needs_resample = cfg.sample_rate != OPUS_RATE;
let in_channels = cfg.stream.channels as usize;
let buf: Arc<Mutex<Vec<f32>>> = Arc::new(Mutex::new(Vec::new()));
let buf2 = buf.clone();
let mut voice_seq: u32 = 0;
let mut timestamp: u32 = 0;
let stream = device.build_input_stream(
cfg.stream,
move |data: &[f32], _| {
let mut b = buf2.lock().unwrap();
if in_channels <= 1 {
b.extend_from_slice(data);
} else {
for frame in data.chunks(in_channels) {
let sum: f32 = frame.iter().sum();
b.push(sum / in_channels as f32);
}
}
},
|e| tracing::error!("input: {e}"),
None,
)?;
stream.play()?;
info!("capture started ({} Hz)", cfg.sample_rate);
loop {
std::thread::sleep(std::time::Duration::from_millis(5));
let mut b = buf.lock().unwrap();
while b.len() >= capture_frame {
let raw: Vec<f32> = b.drain(..capture_frame).collect();
drop(b);
let frame = if needs_resample {
config::resample_rate(&raw, cfg.sample_rate, OPUS_RATE)
} else {
raw
};
let mut pcm = if frame.len() >= opus_frame {
frame[..opus_frame].to_vec()
} else {
let mut padded = frame;
padded.resize(opus_frame, 0.0);
padded
};
let gate_snapshot = gate.lock().unwrap().clone();
TransmitGate::apply_input_volume(&mut pcm, gate_snapshot.input_volume);
if gate_snapshot.noise_suppress {
denoise.process(&mut pcm);
}
let speaking = TransmitGate::should_transmit(&gate_snapshot, &pcm);
local_speaking.store(speaking, Ordering::Relaxed);
if !speaking {
b = buf.lock().unwrap();
continue;
}
apply_bitrate(&mut encoder, &bitrate_bps, &mut last_bitrate_bps)?;
let mut out = vec![0u8; MAX_OPUS];
match encoder.encode_float(&pcm, &mut out) {
Ok(n) => {
out.truncate(n);
if tx
.send(EncodedFrame {
channel_id,
voice_seq,
timestamp,
data: out,
})
.is_err()
{
return Ok(());
}
voice_seq = voice_seq.wrapping_add(1);
timestamp = timestamp.wrapping_add(opus_frame as u32);
}
Err(e) => warn!("encode: {e}"),
}
b = buf.lock().unwrap();
}
}
}
fn frame_samples_at(sample_rate: u32, frame_ms: u32) -> usize {
(sample_rate as usize * frame_ms as usize) / 1000
}
fn apply_bitrate(
encoder: &mut opus::Encoder,
bitrate_bps: &AtomicU32,
last: &mut u32,
) -> Result<()> {
let bps = bitrate_bps.load(Ordering::Relaxed).clamp(8_000, 512_000);
if bps == *last {
return Ok(());
}
*last = bps;
encoder.set_bitrate(opus::Bitrate::Bits(bps as i32))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::frame_samples_at;
#[test]
fn frame_sizes_scale_with_ms() {
assert_eq!(frame_samples_at(48_000, 10), 480);
assert_eq!(frame_samples_at(48_000, 20), 960);
assert_eq!(frame_samples_at(48_000, 40), 1920);
}
}

168
src/audio/pipeline/mod.rs Normal file
View file

@ -0,0 +1,168 @@
mod capture;
mod playback;
use cpal::traits::DeviceTrait;
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering},
mpsc::{self, Receiver, Sender},
};
use tracing::info;
use crate::audio::{
AudioSync, EncodedFrame, IncomingVoice, PipelineConfig, config, devices,
processing::TransmitGate,
};
pub use playback::JitterConfig;
pub struct AudioPipeline {
encoded_rx: Receiver<EncodedFrame>,
voice_tx: Sender<IncomingVoice>,
_encoded_tx: Sender<EncodedFrame>,
gate: Arc<Mutex<TransmitGate>>,
output_volume: Arc<Mutex<f32>>,
jitter_cfg: Arc<Mutex<JitterConfig>>,
bitrate_bps: Arc<AtomicU32>,
/// Local user is currently transmitting (PTT held or VAD above threshold and mic enabled).
local_speaking: Arc<AtomicBool>,
/// UNIX millisecond timestamp of the last voice packet received from a remote peer.
last_remote_voice_ms: Arc<AtomicU64>,
}
impl AudioPipeline {
pub fn poll_encoded(&self) -> Option<EncodedFrame> {
self.encoded_rx.try_recv().ok()
}
pub fn push_voice(&self, packet: IncomingVoice) {
let _ = self.voice_tx.send(packet);
self.last_remote_voice_ms.store(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0),
Ordering::Relaxed,
);
}
/// Returns true if the local user is currently speaking / transmitting.
pub fn local_speaking(&self) -> bool {
self.local_speaking.load(Ordering::Relaxed)
}
/// Returns true if we received a voice packet from a remote peer within the
/// last `within_ms` milliseconds.
pub fn remote_speaking(&self, within_ms: u64) -> bool {
let last = self.last_remote_voice_ms.load(Ordering::Relaxed);
if last == 0 {
return false;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
now.saturating_sub(last) <= within_ms
}
pub fn sync_controls(&self, sync: AudioSync) {
let mut g = self.gate.lock().unwrap();
g.mic_enabled = sync.mic_enabled;
g.vad_mode = sync.vad_mode.min(2) as u8;
g.vad_threshold = sync.vad_threshold;
g.input_volume = sync.input_volume;
g.ptt_held = sync.ptt_held;
g.noise_suppress = sync.noise_suppress;
*self.output_volume.lock().unwrap() = sync.output_volume;
*self.jitter_cfg.lock().unwrap() = JitterConfig {
target_ms: config::jitter_ms_from_setting(sync.jitter_size),
adaptive: sync.adaptive_buffer,
};
self.bitrate_bps.store(
config::bitrate_bps_from_kbps(sync.voice_bitrate_kbps),
Ordering::Relaxed,
);
}
}
pub fn start(cfg: PipelineConfig) -> anyhow::Result<AudioPipeline> {
let input = devices::input_at(cfg.mic_device_index)?;
let output = devices::output_at(cfg.output_device_index)?;
info!(
"audio in: {} out: {}",
input
.description()
.map(|d| d.name().to_owned())
.unwrap_or_default(),
output
.description()
.map(|d| d.name().to_owned())
.unwrap_or_default()
);
let in_cfg = config::AudioConfig::find(&input, true)?;
let out_cfg = config::AudioConfig::find(&output, false)?;
let frame_ms = config::frame_ms_from_interval(cfg.packet_interval);
let (encoded_tx, encoded_rx) = mpsc::channel::<EncodedFrame>();
let (voice_tx, voice_rx) = mpsc::channel::<IncomingVoice>();
let gate = Arc::new(Mutex::new(TransmitGate::default()));
let gate_cap = gate.clone();
let output_volume = Arc::new(Mutex::new(90.0f32));
let vol_play = output_volume.clone();
let jitter_cfg = Arc::new(Mutex::new(JitterConfig {
target_ms: 40,
adaptive: true,
}));
let jitter_play = jitter_cfg.clone();
let bitrate_bps = Arc::new(AtomicU32::new(config::bitrate_bps_from_kbps(
cfg.voice_bitrate_kbps,
)));
let bitrate_cap = bitrate_bps.clone();
let local_speaking = Arc::new(AtomicBool::new(false));
let local_speaking_cap = local_speaking.clone();
let last_remote_voice_ms = Arc::new(AtomicU64::new(0));
let last_remote_voice_play = last_remote_voice_ms.clone();
let channel_id = cfg.channel_key;
let enc_tx = encoded_tx.clone();
std::thread::spawn(move || {
if let Err(e) = capture::run(
input,
in_cfg,
channel_id,
enc_tx,
gate_cap,
frame_ms,
bitrate_cap,
local_speaking_cap,
) {
tracing::error!("capture: {e}");
}
});
std::thread::spawn(move || {
if let Err(e) = playback::run(
output,
out_cfg,
voice_rx,
vol_play,
jitter_play,
last_remote_voice_play,
) {
tracing::error!("playback: {e}");
}
});
Ok(AudioPipeline {
encoded_rx,
voice_tx,
_encoded_tx: encoded_tx,
gate,
output_volume,
jitter_cfg,
bitrate_bps,
local_speaking,
last_remote_voice_ms,
})
}

View file

@ -0,0 +1,149 @@
use crate::jitter::{BufferedPacket, JitterBuffer};
use anyhow::Result;
use cpal::traits::{DeviceTrait, StreamTrait};
use std::sync::{
atomic::{AtomicU64, Ordering},
mpsc::TryRecvError,
Arc, Mutex,
};
use std::time::{Duration, Instant};
use tracing::{info, warn};
use crate::audio::config::{self, AudioConfig, OPUS_RATE};
use crate::audio::IncomingVoice;
#[derive(Debug, Clone)]
pub struct JitterConfig {
pub target_ms: u32,
pub adaptive: bool,
}
pub fn run(
device: cpal::Device,
cfg: AudioConfig,
opus_rx: std::sync::mpsc::Receiver<IncomingVoice>,
output_volume: Arc<Mutex<f32>>,
jitter_cfg: Arc<Mutex<JitterConfig>>,
last_remote_voice_ms: Arc<AtomicU64>,
) -> Result<()> {
let mut decoder = opus::Decoder::new(OPUS_RATE, opus::Channels::Mono)?;
let pb: Arc<Mutex<Vec<f32>>> = Arc::new(Mutex::new(Vec::new()));
let pb2 = pb.clone();
let vol2 = output_volume.clone();
let out_channels = cfg.stream.channels as usize;
let needs_resample = cfg.sample_rate != OPUS_RATE;
let opus_frame = config::frame_samples(OPUS_RATE);
let stream = device.build_output_stream(
cfg.stream,
move |out: &mut [f32], _| {
let gain = (*vol2.lock().unwrap() / 100.0).clamp(0.0, 2.0);
let mut buf = pb2.lock().unwrap();
if out_channels == 1 {
let n = buf.len().min(out.len());
for (o, s) in out[..n].iter_mut().zip(buf[..n].iter()) {
*o = s * gain;
}
buf.drain(..n);
out[n..].fill(0.0);
} else {
let frames = out.len() / out_channels;
for i in 0..frames {
let sample = if i < buf.len() { buf[i] * gain } else { 0.0 };
for ch in 0..out_channels {
out[i * out_channels + ch] = sample;
}
}
let drain_n = frames.min(buf.len());
buf.drain(..drain_n);
}
},
|e| tracing::error!("output: {e}"),
None,
)?;
stream.play()?;
info!("playback started ({} Hz)", cfg.sample_rate);
let clock = Instant::now();
let initial = jitter_cfg.lock().unwrap();
let mut jitter = JitterBuffer::new(initial.target_ms, initial.adaptive);
drop(initial);
let mut stream_open = true;
while stream_open || !jitter.is_empty() {
let cfg_now = jitter_cfg.lock().unwrap().clone();
jitter.set_target_ms(cfg_now.target_ms);
if jitter.is_adaptive() != cfg_now.adaptive {
let target = jitter.target_ms();
let now_ms = clock.elapsed().as_millis() as u64;
let mut new_jb = JitterBuffer::new(target, cfg_now.adaptive);
while let Some(mut pkt) = jitter.pop_ready(now_ms) {
pkt.arrived_at = now_ms;
new_jb.push(pkt);
}
jitter = new_jb;
tracing::info!(adaptive = cfg_now.adaptive, "jitter: adaptive mode toggled");
}
loop {
match opus_rx.try_recv() {
Ok(pkt) => {
last_remote_voice_ms.store(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0),
Ordering::Relaxed,
);
jitter.push(BufferedPacket {
voice_seq: pkt.voice_seq,
timestamp: pkt.timestamp,
channel_id: pkt.channel_id,
opus_data: pkt.opus_data,
arrived_at: clock.elapsed().as_millis() as u64,
});
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
stream_open = false;
break;
}
}
}
let now_ms = clock.elapsed().as_millis() as u64;
while let Some(pkt) = jitter.pop_ready(now_ms) {
decode_into_playback(
&mut decoder,
&pkt.opus_data,
opus_frame,
needs_resample,
cfg.sample_rate,
&pb,
);
}
std::thread::sleep(Duration::from_millis(2));
}
Ok(())
}
fn decode_into_playback(
decoder: &mut opus::Decoder,
data: &[u8],
opus_frame: usize,
needs_resample: bool,
device_rate: u32,
pb: &Arc<Mutex<Vec<f32>>>,
) {
let mut pcm = vec![0f32; opus_frame];
match decoder.decode_float(data, &mut pcm, false) {
Ok(n) => {
pcm.truncate(n);
let samples = if needs_resample {
config::resample_rate(&pcm, OPUS_RATE, device_rate)
} else {
pcm
};
pb.lock().unwrap().extend_from_slice(&samples);
}
Err(e) => warn!("decode: {e}"),
}
}

142
src/audio/processing/mod.rs Normal file
View file

@ -0,0 +1,142 @@
mod rnnoise;
pub use rnnoise::DenoiseState;
pub struct TransmitGate {
pub mic_enabled: bool,
pub vad_mode: u8,
pub vad_threshold: f32,
pub ptt_held: bool,
pub input_volume: f32,
pub noise_suppress: bool,
}
impl Default for TransmitGate {
fn default() -> Self {
Self {
mic_enabled: true,
vad_mode: 0,
vad_threshold: 40.0,
ptt_held: false,
input_volume: 80.0,
noise_suppress: false,
}
}
}
impl Clone for TransmitGate {
fn clone(&self) -> Self {
Self {
mic_enabled: self.mic_enabled,
vad_mode: self.vad_mode,
vad_threshold: self.vad_threshold,
ptt_held: self.ptt_held,
input_volume: self.input_volume,
noise_suppress: self.noise_suppress,
}
}
}
impl std::fmt::Debug for TransmitGate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TransmitGate")
.field("mic_enabled", &self.mic_enabled)
.field("vad_mode", &self.vad_mode)
.field("vad_threshold", &self.vad_threshold)
.field("ptt_held", &self.ptt_held)
.field("input_volume", &self.input_volume)
.field("noise_suppress", &self.noise_suppress)
.finish()
}
}
pub fn frame_rms(pcm: &[f32]) -> f32 {
if pcm.is_empty() {
return 0.0;
}
let mean_sq: f32 = pcm.iter().map(|s| s * s).sum::<f32>() / pcm.len() as f32;
mean_sq.sqrt()
}
fn vad_rms_threshold(ui_threshold: f32) -> f32 {
0.002 + (ui_threshold.clamp(0.0, 100.0) / 100.0) * 0.118
}
impl TransmitGate {
pub fn apply_input_volume(pcm: &mut [f32], volume_pct: f32) {
let gain = (volume_pct / 100.0).clamp(0.0, 2.0);
if (gain - 1.0).abs() < f32::EPSILON {
return;
}
for s in pcm {
*s *= gain;
}
}
pub fn should_transmit(&self, pcm: &[f32]) -> bool {
if !self.mic_enabled {
return false;
}
match self.vad_mode {
2 => true,
0 => self.ptt_held,
1 => frame_rms(pcm) >= vad_rms_threshold(self.vad_threshold),
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn loud_frame() -> Vec<f32> {
vec![0.25f32; 960]
}
fn quiet_frame() -> Vec<f32> {
vec![0.0001f32; 960]
}
#[test]
fn mic_disabled_blocks() {
let gate = TransmitGate {
mic_enabled: false,
vad_mode: 2,
..Default::default()
};
assert!(!gate.should_transmit(&loud_frame()));
}
#[test]
fn always_on_when_mic_enabled() {
let gate = TransmitGate {
vad_mode: 2,
..Default::default()
};
assert!(gate.should_transmit(&quiet_frame()));
}
#[test]
fn ptt_requires_held() {
let mut gate = TransmitGate {
vad_mode: 0,
ptt_held: false,
..Default::default()
};
assert!(!gate.should_transmit(&loud_frame()));
gate.ptt_held = true;
assert!(gate.should_transmit(&loud_frame()));
}
#[test]
fn vad_uses_threshold() {
let gate = TransmitGate {
vad_mode: 1,
vad_threshold: 40.0,
..Default::default()
};
assert!(!gate.should_transmit(&quiet_frame()));
assert!(gate.should_transmit(&loud_frame()));
}
}

View file

@ -0,0 +1,46 @@
#[cfg(feature = "rnnoise")]
mod imp {
use anyhow::Result;
const FRAME_SIZE: usize = nnnoiseless::DenoiseState::FRAME_SIZE;
pub struct DenoiseState {
inner: Box<nnnoiseless::DenoiseState<'static>>,
out_buf: Vec<f32>,
}
impl DenoiseState {
pub fn new() -> Result<Self> {
let inner = nnnoiseless::DenoiseState::new();
Ok(Self {
inner,
out_buf: vec![0.0; FRAME_SIZE],
})
}
pub fn process(&mut self, pcm: &mut [f32]) {
for chunk in pcm.chunks_exact_mut(FRAME_SIZE) {
let input: Vec<f32> = chunk.iter().map(|&s| s * 32768.0).collect();
self.inner.process_frame(&mut self.out_buf, &input);
for (out, &denoised) in chunk.iter_mut().zip(self.out_buf.iter()) {
*out = (denoised / 32768.0).clamp(-1.0, 1.0);
}
}
}
}
}
#[cfg(not(feature = "rnnoise"))]
mod imp {
pub struct DenoiseState;
impl DenoiseState {
pub fn new() -> anyhow::Result<Self> {
Ok(Self)
}
pub fn process(&mut self, _pcm: &mut [f32]) {}
}
}
pub use imp::DenoiseState;

131
src/bin/e2e_voice.rs Normal file
View file

@ -0,0 +1,131 @@
//! Headless voice path smoke test: two TCP sessions, UDP relay between them.
//!
//! Requires gateway and voice-node running:
//! cargo run -p vnox-gateway -- --config dev/config.toml
//! cargo run -p vnox-voice-node -- --config dev/config.toml
use anyhow::{Context, Result, anyhow};
use tokio::net::{TcpStream, UdpSocket};
use tokio::time::{Duration, timeout};
use tracing::info;
use vnox_client::{
identity,
net::{crypto::SessionCrypto, framing, handshake, payloads::*, voice},
};
const GATEWAY: &str = "127.0.0.1:7600";
const VOICE_CHANNEL: &str = "voice";
struct Session {
_stream: TcpStream,
_crypto: SessionCrypto,
udp: UdpSocket,
voice_endpoint: std::net::SocketAddr,
}
async fn connect_client(nickname: &str) -> Result<Session> {
let identity = identity::generate(nickname);
let mut stream = TcpStream::connect(GATEWAY).await.context("TCP connect")?;
let mut seq = 0u32;
let (_session_id, _node_name, _private_mode, crypto) =
handshake::run(&mut stream, &identity, &mut seq).await?;
framing::io::write_encrypted(
&mut stream,
PID_JOIN_CHANNEL,
&mut seq,
&serde_json::to_vec(&JoinChannelPayload {
channel_id: VOICE_CHANNEL.into(),
})?,
&crypto,
)
.await?;
let pkt = framing::io::read_encrypted(&mut stream, &crypto).await?;
if pkt.packet_id != PID_CHANNEL_STATE {
return Err(anyhow!(
"expected CHANNEL_STATE, got 0x{:04X}",
pkt.packet_id
));
}
let state: ChannelStatePayload = serde_json::from_slice(&pkt.payload)?;
let voice_endpoint: std::net::SocketAddr = state
.voice_endpoint
.parse()
.context("parse voice_endpoint")?;
let udp = UdpSocket::bind("0.0.0.0:0").await?;
info!(
"{nickname}: voice_endpoint={voice_endpoint} udp={}",
udp.local_addr()?
);
Ok(Session {
_stream: stream,
_crypto: crypto,
udp,
voice_endpoint,
})
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(std::env::var("VNOX_LOG").unwrap_or_else(|_| "info".into()))
.init();
let channel_key = voice::channel_key(VOICE_CHANNEL);
let marker = b"vnox-e2e-test-payload";
// Dummy crypto for the test packet
let dummy_shared = [0u8; 32];
let dummy_crypto = SessionCrypto::derive(&dummy_shared, "test");
let sender_pubkey = [0u8; 32];
let packet = voice::build_packet(channel_key, 1, 0, marker, &dummy_crypto, 0, &sender_pubkey);
let alice = connect_client("alice").await?;
let bob = connect_client("bob").await?;
// Register both clients on the voice node.
alice
.udp
.send_to(&packet, alice.voice_endpoint)
.await
.context("alice register")?;
bob.udp
.send_to(&packet, alice.voice_endpoint)
.await
.context("bob register")?;
tokio::time::sleep(Duration::from_millis(50)).await;
let recv = tokio::spawn(async move {
let mut buf = vec![0u8; 1472];
let (len, from) = bob.udp.recv_from(&mut buf).await.context("bob recv")?;
if len >= 20 && &buf[20..len] == marker {
info!("PASS: bob received relayed packet from {from} ({len} bytes)");
Ok(())
} else {
Err(anyhow!("unexpected UDP payload (len={len})"))
}
});
// Alice speaks; voice node should relay to bob.
alice
.udp
.send_to(&packet, alice.voice_endpoint)
.await
.context("alice send")?;
info!(
"sent voice packet to {} (channel_key={channel_key})",
alice.voice_endpoint
);
timeout(Duration::from_secs(5), recv)
.await
.context("timed out waiting for voice relay")?
.context("voice recv task failed")??;
info!("e2e voice test passed");
Ok(())
}

189
src/identity.rs Normal file
View file

@ -0,0 +1,189 @@
use anyhow::Result;
use ed25519_dalek::{SigningKey, VerifyingKey};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::identity_vault::Vault;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Identity {
pub nickname: String,
/// hex-encoded public key (64 chars)
pub pubkey_hex: String,
/// hex-encoded private key — never leave device
#[serde(skip_serializing_if = "Option::is_none")]
pub privkey_hex: Option<String>,
}
impl Identity {
/// Short form of the pubkey for display (first 8 hex chars + …)
pub fn short_id(&self) -> String {
format!("{}…", &self.pubkey_hex[..8])
}
pub fn signing_key(&self) -> Option<SigningKey> {
let hex = self.privkey_hex.as_ref()?;
let bytes = hex::decode(hex).ok()?;
SigningKey::from_bytes(bytes.as_slice().try_into().ok()?).into()
}
pub fn verifying_key(&self) -> Option<VerifyingKey> {
let bytes = hex::decode(&self.pubkey_hex).ok()?;
VerifyingKey::from_bytes(bytes.as_slice().try_into().ok()?).ok()
}
}
/// Load identity from disk or generate a new one.
///
/// Tries the vault format first (`identity.vault.json`). Falls back to the
/// legacy plain `identity.json` for existing installs.
///
/// If a vault is found and is encrypted, returns
/// `LoadError::PassphraseRequired` so the caller can prompt the user.
pub fn load_or_generate() -> Result<Identity> {
let vault_path = vault_path();
let legacy_path = identity_path();
if vault_path.exists() {
let text = std::fs::read_to_string(&vault_path)?;
let vault: Vault = serde_json::from_str(&text)?;
if vault.is_encrypted() {
return Err(anyhow::anyhow!(LoadError::PassphraseRequired {
path: vault_path,
}));
}
let inner = vault.open(None)?;
let identity: Identity = serde_json::from_str(&inner)?;
return Ok(identity);
}
if legacy_path.exists() {
let text = std::fs::read_to_string(&legacy_path)?;
let identity: Identity = serde_json::from_str(&text)?;
return Ok(identity);
}
// Generate new keypair and save as plain vault.
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
let identity = Identity {
nickname: "user".into(),
pubkey_hex: hex::encode(verifying_key.to_bytes()),
privkey_hex: Some(hex::encode(signing_key.to_bytes())),
};
save(&identity, None)?;
Ok(identity)
}
/// Load an encrypted vault with the given passphrase.
pub fn load_with_passphrase(passphrase: &str) -> Result<Identity> {
let vault_path = vault_path();
let text = std::fs::read_to_string(&vault_path)?;
let vault: Vault = serde_json::from_str(&text)?;
let inner = vault.open(Some(passphrase))?;
let identity: Identity = serde_json::from_str(&inner)?;
Ok(identity)
}
/// Save identity to disk, optionally encrypting with a passphrase.
/// When `passphrase` is `None` or empty, writes a plain vault.
pub fn save(identity: &Identity, passphrase: Option<&str>) -> Result<()> {
let path = vault_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let inner_json = serde_json::to_string_pretty(identity)?;
let vault = Vault::seal(&inner_json, passphrase)?;
std::fs::write(&path, serde_json::to_string_pretty(&vault)?)?;
Ok(())
}
/// True if the on-disk vault is encrypted and requires a passphrase.
pub fn is_vault_encrypted() -> bool {
let path = vault_path();
if !path.exists() {
return false;
}
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(_) => return false,
};
serde_json::from_str::<Vault>(&text)
.map(|v| v.is_encrypted())
.unwrap_or(false)
}
/// Ephemeral identity for tests (not persisted).
pub fn generate(nickname: &str) -> Identity {
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
Identity {
nickname: nickname.into(),
pubkey_hex: hex::encode(verifying_key.to_bytes()),
privkey_hex: Some(hex::encode(signing_key.to_bytes())),
}
}
/// Export the identity to a portable keyfile (JSON).
///
/// When a passphrase is provided, the keyfile is encrypted with Argon2id +
/// ChaCha20-Poly1305 (same scheme as the on-disk vault). When no passphrase
/// is provided, the keyfile is plain JSON — useful for tests but **not**
/// recommended for production use.
///
/// The returned string can be written to a `.vnoxkey` file or pasted into
/// a chat message for transfer.
pub fn export_keyfile(identity: &Identity, passphrase: Option<&str>) -> Result<String> {
let inner = serde_json::to_string_pretty(identity)?;
let vault = crate::identity_vault::Vault::seal(&inner, passphrase)?;
Ok(serde_json::to_string_pretty(&vault)?)
}
/// Import an identity from a previously exported keyfile.
///
/// If the keyfile is encrypted, a passphrase must be provided. Returns the
/// deserialized identity without writing it to disk — call `save()` to persist.
pub fn import_keyfile(keyfile_json: &str, passphrase: Option<&str>) -> Result<Identity> {
let vault: crate::identity_vault::Vault = serde_json::from_str(keyfile_json)?;
let inner = vault.open(passphrase)?;
let identity: Identity = serde_json::from_str(&inner)?;
Ok(identity)
}
/// Errors that can occur during identity loading.
#[derive(Debug)]
pub enum LoadError {
/// The vault on disk is encrypted and needs a passphrase to unlock.
PassphraseRequired { path: PathBuf },
}
impl std::fmt::Display for LoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LoadError::PassphraseRequired { path } => {
write!(
f,
"identity vault at {} is encrypted; passphrase required",
path.display()
)
}
}
}
}
impl std::error::Error for LoadError {}
fn vault_path() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("vnox")
.join("identity.vault.json")
}
fn identity_path() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("vnox")
.join("identity.json")
}

158
src/identity_vault.rs Normal file
View file

@ -0,0 +1,158 @@
//! Encrypted vault for the identity keypair.
//!
//! Format on disk (JSON):
//! ```json
//! {
//! "version": 1,
//! "scheme": "argon2id+chacha20-poly1305",
//! "salt": "<hex 16 bytes>",
//! "nonce": "<hex 12 bytes>",
//! "ciphertext": "<hex, plaintext is the inner Identity JSON>"
//! }
//! ```
//!
//! When `passphrase` is `None`, the identity is stored as plain JSON (legacy behavior).
use anyhow::Result;
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, aead::Aead};
use serde::{Deserialize, Serialize};
const VAULT_VERSION: u32 = 1;
const VAULT_SCHEME: &str = "argon2id+chacha20-poly1305";
const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const KEY_LEN: usize = 32;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vault {
pub version: u32,
pub scheme: String,
/// Hex-encoded salt for Argon2id.
#[serde(default)]
pub salt: String,
/// Hex-encoded ChaCha20-Poly1305 nonce.
#[serde(default)]
pub nonce: String,
/// Hex-encoded ciphertext (encrypts the inner Identity JSON).
/// Empty when no passphrase is set.
#[serde(default)]
pub ciphertext: String,
/// Plaintext Identity JSON — only set when no passphrase is used.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plaintext: Option<String>,
}
impl Vault {
/// Seal the given JSON-serialized identity with a passphrase.
/// If `passphrase` is empty, stores as plaintext (no encryption).
pub fn seal(identity_json: &str, passphrase: Option<&str>) -> Result<Self> {
if passphrase.map(|p| p.is_empty()).unwrap_or(true) {
return Ok(Self {
version: VAULT_VERSION,
scheme: "plain".into(),
salt: String::new(),
nonce: String::new(),
ciphertext: String::new(),
plaintext: Some(identity_json.into()),
});
}
let mut salt = [0u8; SALT_LEN];
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut salt);
let mut nonce_bytes = [0u8; NONCE_LEN];
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut nonce_bytes);
let key = derive_key(passphrase.unwrap(), &salt)?;
let cipher = ChaCha20Poly1305::new_from_slice(&key)
.map_err(|e| anyhow::anyhow!("chacha key init: {e}"))?;
let nonce = chacha20poly1305::Nonce::from_slice(&nonce_bytes);
let ct = cipher
.encrypt(nonce, identity_json.as_bytes())
.map_err(|e| anyhow::anyhow!("encrypt identity: {e:?}"))?;
Ok(Self {
version: VAULT_VERSION,
scheme: VAULT_SCHEME.into(),
salt: hex::encode(salt),
nonce: hex::encode(nonce_bytes),
ciphertext: hex::encode(&ct),
plaintext: None,
})
}
/// Open the vault and return the inner Identity JSON.
/// If `passphrase` is None but the vault is encrypted, returns an error.
pub fn open(&self, passphrase: Option<&str>) -> Result<String> {
if self.scheme == "plain" {
return self
.plaintext
.clone()
.ok_or_else(|| anyhow::anyhow!("plain vault has no plaintext"));
}
let passphrase = passphrase
.ok_or_else(|| anyhow::anyhow!("vault is encrypted but no passphrase provided"))?;
let salt = hex::decode(&self.salt).map_err(|e| anyhow::anyhow!("bad vault salt: {e}"))?;
let nonce_bytes =
hex::decode(&self.nonce).map_err(|e| anyhow::anyhow!("bad vault nonce: {e}"))?;
let ct = hex::decode(&self.ciphertext)
.map_err(|e| anyhow::anyhow!("bad vault ciphertext: {e}"))?;
let key = derive_key(passphrase, &salt)?;
let cipher = ChaCha20Poly1305::new_from_slice(&key)
.map_err(|e| anyhow::anyhow!("chacha key init: {e}"))?;
let nonce = chacha20poly1305::Nonce::from_slice(&nonce_bytes);
let pt = cipher
.decrypt(nonce, ct.as_ref())
.map_err(|_| anyhow::anyhow!("wrong passphrase or corrupted vault"))?;
String::from_utf8(pt).map_err(|e| anyhow::anyhow!("vault plaintext is not UTF-8: {e}"))
}
pub fn is_encrypted(&self) -> bool {
self.scheme != "plain"
}
}
fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; KEY_LEN]> {
// m=64 MiB, t=3, p=4 — moderate cost, ~250 ms on a modern CPU.
let params = Params::new(64 * 1024, 3, 4, Some(KEY_LEN))
.map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut out = [0u8; KEY_LEN];
argon
.hash_password_into(passphrase.as_bytes(), salt, &mut out)
.map_err(|e| anyhow::anyhow!("argon2 derive: {e}"))?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_encrypted() {
let inner = r#"{"nickname":"alice","pubkey_hex":"abcd","privkey_hex":"0011"}"#;
let v = Vault::seal(inner, Some("hunter2")).unwrap();
assert!(v.is_encrypted());
let opened = v.open(Some("hunter2")).unwrap();
assert_eq!(opened, inner);
}
#[test]
fn wrong_passphrase_fails() {
let v = Vault::seal("secret", Some("hunter2")).unwrap();
assert!(v.open(Some("wrong")).is_err());
}
#[test]
fn plaintext_roundtrip() {
let inner = r#"{"nickname":"bob"}"#;
let v = Vault::seal(inner, None).unwrap();
assert!(!v.is_encrypted());
let opened = v.open(None).unwrap();
assert_eq!(opened, inner);
}
#[test]
fn empty_passphrase_treated_as_plain() {
let v = Vault::seal("x", Some("")).unwrap();
assert!(!v.is_encrypted());
}
}

75
src/jitter/adaptive.rs Normal file
View file

@ -0,0 +1,75 @@
use tracing::info;
use super::JitterBuffer;
pub const MIN_TARGET_MS: u32 = 20;
pub const MAX_TARGET_MS: u32 = 150;
pub const JITTER_WINDOW: usize = 64;
const LOSS_INCREASE_THRESH: u32 = 5;
const LOSS_DECREASE_THRESH: u32 = 1;
const STABLE_WINDOWS_BEFORE_DECREASE: u32 = 10;
pub const LOSS_WINDOW_PACKETS: u32 = 50;
const ADJUST_STEP_MS: u32 = 5;
impl JitterBuffer {
pub(super) fn observed_jitter_ms(&self) -> u32 {
if self.arrival_count < 2 {
return 0;
}
let mut deltas = Vec::with_capacity(self.arrival_count - 1);
for i in 0..self.arrival_count - 1 {
let idx0 = (self.arrival_idx + JITTER_WINDOW - self.arrival_count + i) % JITTER_WINDOW;
let idx1 =
(self.arrival_idx + JITTER_WINDOW - self.arrival_count + i + 1) % JITTER_WINDOW;
let delta = self.arrival_times[idx1].saturating_sub(self.arrival_times[idx0]);
deltas.push(delta);
}
if deltas.is_empty() {
return 0;
}
deltas.sort_unstable();
let p90_idx = ((deltas.len() as f64) * 0.90).ceil() as usize - 1;
let p90_idx = p90_idx.min(deltas.len() - 1);
deltas[p90_idx] as u32
}
pub(super) fn adapt_target(&mut self) {
let loss_pct = if self.total_expected > 0 {
(self.loss_count as f64 / self.total_expected as f64 * 100.0) as u32
} else {
0
};
let jitter_ms = self.observed_jitter_ms();
let jitter_based_target = (jitter_ms * 2).clamp(MIN_TARGET_MS, MAX_TARGET_MS);
if loss_pct >= LOSS_INCREASE_THRESH {
let new_target = (self.target_ms + ADJUST_STEP_MS).min(MAX_TARGET_MS);
if new_target != self.target_ms {
info!(
target_ms = new_target,
loss_pct, jitter_ms, "adaptive: increasing target due to packet loss"
);
self.target_ms = new_target;
}
self.stable_windows = 0;
} else if loss_pct <= LOSS_DECREASE_THRESH {
self.stable_windows += 1;
if self.stable_windows >= STABLE_WINDOWS_BEFORE_DECREASE {
let candidate = self.target_ms.saturating_sub(ADJUST_STEP_MS);
let new_target = candidate.max(jitter_based_target).max(MIN_TARGET_MS);
if new_target != self.target_ms {
info!(
target_ms = new_target,
loss_pct, jitter_ms, "adaptive: decreasing target, link stable"
);
self.target_ms = new_target;
}
self.stable_windows = 0;
}
} else {
self.stable_windows = 0;
}
self.loss_count = 0;
self.total_expected = 0;
}
}

100
src/jitter/mod.rs Normal file
View file

@ -0,0 +1,100 @@
use std::collections::BTreeMap;
pub mod adaptive;
pub mod relay;
pub use adaptive::{JITTER_WINDOW, LOSS_WINDOW_PACKETS, MAX_TARGET_MS, MIN_TARGET_MS};
pub struct JitterBuffer {
target_ms: u32,
adaptive: bool,
packets: BTreeMap<u32, BufferedPacket>,
last_played: Option<u32>,
arrival_times: Vec<u64>,
arrival_idx: usize,
arrival_count: usize,
loss_count: u32,
total_expected: u32,
stable_windows: u32,
}
impl JitterBuffer {
pub fn new(target_ms: u32, adaptive: bool) -> Self {
Self {
target_ms,
adaptive,
packets: BTreeMap::new(),
last_played: None,
arrival_times: vec![0u64; JITTER_WINDOW],
arrival_idx: 0,
arrival_count: 0,
loss_count: 0,
total_expected: 0,
stable_windows: 0,
}
}
pub fn push(&mut self, pkt: BufferedPacket) {
let arrived = pkt.arrived_at;
self.arrival_times[self.arrival_idx] = arrived;
self.arrival_idx = (self.arrival_idx + 1) % JITTER_WINDOW;
if self.arrival_count < JITTER_WINDOW {
self.arrival_count += 1;
}
let gap = self
.packets
.keys()
.next_back()
.map(|&highest| {
if pkt.voice_seq > highest {
pkt.voice_seq.wrapping_sub(highest).saturating_sub(1)
} else {
0
}
})
.unwrap_or(0);
if gap > 0 {
self.loss_count += gap;
}
self.total_expected += 1 + gap;
self.packets.insert(pkt.voice_seq, pkt);
if self.adaptive && self.total_expected >= LOSS_WINDOW_PACKETS {
self.adapt_target();
}
}
pub fn len(&self) -> usize {
self.packets.len()
}
pub fn is_empty(&self) -> bool {
self.packets.is_empty()
}
pub fn set_target_ms(&mut self, target_ms: u32) {
self.target_ms = target_ms.clamp(MIN_TARGET_MS, MAX_TARGET_MS);
}
pub fn target_ms(&self) -> u32 {
self.target_ms
}
pub fn is_adaptive(&self) -> bool {
self.adaptive
}
}
#[derive(Debug)]
pub struct BufferedPacket {
pub voice_seq: u32,
pub timestamp: u32,
pub channel_id: u64,
pub opus_data: Vec<u8>,
pub arrived_at: u64,
}
#[cfg(test)]
mod tests;

44
src/jitter/relay.rs Normal file
View file

@ -0,0 +1,44 @@
use super::{BufferedPacket, JitterBuffer};
impl JitterBuffer {
pub fn pop_ready(&mut self, now_ms: u64) -> Option<BufferedPacket> {
if self.adaptive {
return self.pop_ready_adaptive(now_ms);
}
self.pop_ready_fixed(now_ms)
}
pub fn has_gap(&self) -> bool {
match self.last_played {
None => false,
Some(last) => {
let expected = last.wrapping_add(1);
!self.packets.contains_key(&expected) && !self.packets.is_empty()
}
}
}
fn pop_ready_fixed(&mut self, now_ms: u64) -> Option<BufferedPacket> {
let seq = *self.packets.keys().next()?;
let pkt = self.packets.get(&seq)?;
let buffered_for = now_ms.saturating_sub(pkt.arrived_at);
if buffered_for >= self.target_ms as u64 {
let pkt = self.packets.remove(&seq)?;
self.last_played = Some(seq);
return Some(pkt);
}
None
}
fn pop_ready_adaptive(&mut self, now_ms: u64) -> Option<BufferedPacket> {
let seq = *self.packets.keys().next()?;
let pkt = self.packets.get(&seq)?;
let buffered_for = now_ms.saturating_sub(pkt.arrived_at);
if buffered_for >= self.target_ms as u64 {
let pkt = self.packets.remove(&seq)?;
self.last_played = Some(seq);
return Some(pkt);
}
None
}
}

103
src/jitter/tests.rs Normal file
View file

@ -0,0 +1,103 @@
use super::*;
fn pkt(seq: u32, arrived_at: u64) -> BufferedPacket {
BufferedPacket {
voice_seq: seq,
timestamp: 0,
channel_id: 1,
opus_data: vec![seq as u8],
arrived_at,
}
}
#[test]
fn pop_ready_waits_for_target_ms() {
let mut jb = JitterBuffer::new(20, false);
jb.push(pkt(1, 100));
assert!(jb.pop_ready(110).is_none());
assert_eq!(jb.pop_ready(120).unwrap().voice_seq, 1);
assert!(jb.pop_ready(120).is_none());
}
#[test]
fn pop_ready_releases_in_sequence_order() {
let mut jb = JitterBuffer::new(0, false);
jb.push(pkt(2, 0));
jb.push(pkt(1, 0));
assert_eq!(jb.pop_ready(0).unwrap().voice_seq, 1);
assert_eq!(jb.pop_ready(0).unwrap().voice_seq, 2);
}
#[test]
fn has_gap_after_played_seq() {
let mut jb = JitterBuffer::new(0, false);
jb.push(pkt(1, 0));
jb.pop_ready(0);
jb.push(pkt(3, 0));
assert!(jb.has_gap());
}
#[test]
fn no_gap_when_next_seq_present() {
let mut jb = JitterBuffer::new(0, false);
jb.push(pkt(1, 0));
jb.pop_ready(0);
jb.push(pkt(2, 0));
assert!(!jb.has_gap());
}
#[test]
fn adaptive_mode_increases_on_loss() {
let mut jb = JitterBuffer::new(40, true);
jb.push(pkt(1, 0));
jb.push(pkt(3, 1));
for i in 4..=50 {
jb.push(pkt(i, i as u64));
}
assert!(jb.target_ms <= MAX_TARGET_MS);
}
#[test]
fn set_target_ms_clamps() {
let mut jb = JitterBuffer::new(40, false);
jb.set_target_ms(0);
assert_eq!(jb.target_ms, MIN_TARGET_MS);
jb.set_target_ms(500);
assert_eq!(jb.target_ms, MAX_TARGET_MS);
}
#[test]
fn adaptive_mode_respects_flag() {
let mut jb = JitterBuffer::new(40, false);
jb.push(pkt(1, 0));
for i in 2..=100 {
jb.push(pkt(i, i as u64));
}
assert_eq!(jb.target_ms, 40);
}
#[test]
fn observed_jitter_smooth_timeline() {
let mut jb = JitterBuffer::new(40, true);
for i in 0..JITTER_WINDOW {
jb.push(pkt(i as u32, (i as u64) * 10));
}
let jitter = jb.observed_jitter_ms();
assert!(
(8..=12).contains(&jitter),
"jitter = {jitter} (expected ~10)"
);
}
#[test]
fn observed_jitter_spike() {
let mut jb = JitterBuffer::new(40, true);
let times = [
0, 10, 20, 70, 80, 90, 140, 150, 160, 210, 220, 230, 280, 290, 300,
];
for (i, &t) in times.iter().enumerate() {
jb.push(pkt(i as u32, t));
}
let jitter = jb.observed_jitter_ms();
assert!(jitter >= 40, "jitter = {jitter} (expected >= 40)");
}

5
src/lib.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod audio;
pub mod identity;
pub mod identity_vault;
pub mod jitter;
pub mod net;

39
src/main.rs Normal file
View file

@ -0,0 +1,39 @@
mod ui;
pub use ui::VnoxApp;
pub use ui::{chat, connect, keybind, members, settings, sidebar, state, theme, titlebar};
use anyhow::Result;
use tracing::info;
use tracing_subscriber::EnvFilter;
use vnox_client::{identity, net};
fn main() -> Result<()> {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new("info,wgpu=warn,wgpu_hal=warn,egui_wgpu=warn,naga=warn")
});
tracing_subscriber::fmt().with_env_filter(filter).init();
let identity = identity::load_or_generate()?;
info!("identity: {}", identity.short_id());
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let _guard = rt.enter();
let net = net::spawn(identity.clone());
eframe::run_native(
"Vnox",
eframe::NativeOptions {
viewport: eframe::egui::ViewportBuilder::default()
.with_title("Vnox")
.with_inner_size([900.0, 560.0])
.with_min_inner_size([600.0, 400.0]),
..Default::default()
},
Box::new(|cc| Ok(Box::new(ui::VnoxApp::new(cc, identity, net)))),
)
.map_err(|e| anyhow::anyhow!("eframe: {e}"))
}

46
src/net/crypto/cipher.rs Normal file
View file

@ -0,0 +1,46 @@
use chacha20poly1305::{
ChaCha20Poly1305, Key, KeyInit, Nonce,
aead::{Aead, Payload},
};
fn make_nonce(cid: &[u8; 8], seq: u64) -> [u8; 12] {
let mut nonce = [0u8; 12];
nonce[..8].copy_from_slice(cid);
nonce[8..12].copy_from_slice(&(seq as u32).to_le_bytes());
nonce
}
pub fn encrypt_with_key(key: &[u8; 32], cid: &[u8; 8], seq: u64, plaintext: &[u8]) -> Vec<u8> {
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce_bytes = make_nonce(cid, seq);
let nonce = Nonce::from_slice(&nonce_bytes);
cipher
.encrypt(
nonce,
Payload {
msg: plaintext,
aad: &[],
},
)
.expect("ChaCha20-Poly1305 encryption infallible")
}
pub fn decrypt_with_key(
key: &[u8; 32],
cid: &[u8; 8],
seq: u64,
ciphertext: &[u8],
) -> anyhow::Result<Vec<u8>> {
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce_bytes = make_nonce(cid, seq);
let nonce = Nonce::from_slice(&nonce_bytes);
cipher
.decrypt(
nonce,
Payload {
msg: ciphertext,
aad: &[],
},
)
.map_err(|e| anyhow::anyhow!("decryption failed: {e:?}"))
}

99
src/net/crypto/mod.rs Normal file
View file

@ -0,0 +1,99 @@
use hkdf::Hkdf;
use sha2::{Digest, Sha256};
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
pub mod cipher;
/// Session encryption context (mirrors gateway/src/proto/crypto.rs).
///
/// Two derived keys:
/// - `c2s_key`: client → server
/// - `s2c_key`: server → client
///
/// Nonce: `cid (8 bytes) || packet_seq (4 bytes LE)`.
#[derive(Clone)]
pub struct SessionCrypto {
c2s_key: [u8; 32],
s2c_key: [u8; 32],
cid: [u8; 8],
}
impl SessionCrypto {
pub fn derive(shared_secret: &[u8; 32], session_id: &str) -> Self {
let salt = b"VNOX-LNEx-KDF-v1";
let hk = Hkdf::<Sha256>::new(Some(salt), shared_secret);
let mut keys = [0u8; 64];
hk.expand(b"session-keys", &mut keys)
.expect("64 bytes within HKDF max");
let c2s_key: [u8; 32] = keys[..32].try_into().unwrap();
let s2c_key: [u8; 32] = keys[32..64].try_into().unwrap();
let hash = Sha256::digest(session_id.as_bytes());
let cid: [u8; 8] = hash[..8].try_into().unwrap();
Self {
c2s_key,
s2c_key,
cid,
}
}
pub fn encrypt_c2s(&self, seq: u64, plaintext: &[u8]) -> Vec<u8> {
cipher::encrypt_with_key(&self.c2s_key, &self.cid, seq, plaintext)
}
pub fn decrypt_s2c(&self, seq: u64, ciphertext: &[u8]) -> anyhow::Result<Vec<u8>> {
cipher::decrypt_with_key(&self.s2c_key, &self.cid, seq, ciphertext)
}
pub fn decrypt_c2s(&self, seq: u64, ciphertext: &[u8]) -> anyhow::Result<Vec<u8>> {
cipher::decrypt_with_key(&self.c2s_key, &self.cid, seq, ciphertext)
}
pub fn c2s_key(&self) -> &[u8; 32] {
&self.c2s_key
}
pub fn cid(&self) -> &[u8; 8] {
&self.cid
}
pub fn new_ephemeral() -> (EphemeralSecret, PublicKey) {
let mut rng = rand::thread_rng();
let sk = EphemeralSecret::random_from_rng(&mut rng);
let pk = PublicKey::from(&sk);
(sk, pk)
}
pub fn ecdh(secret: EphemeralSecret, peer_public: &PublicKey) -> SharedSecret {
secret.diffie_hellman(peer_public)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypt_then_decrypt_roundtrip() {
let shared_secret = [0xABu8; 32];
let crypto = SessionCrypto::derive(&shared_secret, "test-session");
let plaintext = b"hello encrypted world";
let ct = crypto.encrypt_c2s(42, plaintext);
let decrypted = crypto.decrypt_c2s(42, &ct).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn tampered_ciphertext_fails() {
let shared_secret = [0xABu8; 32];
let crypto = SessionCrypto::derive(&shared_secret, "test-session");
let mut ct = crypto.encrypt_c2s(0, b"data");
ct[5] ^= 0xFF;
assert!(crypto.decrypt_c2s(0, &ct).is_err());
}
}

51
src/net/framing/io.rs Normal file
View file

@ -0,0 +1,51 @@
use anyhow::{Result, anyhow};
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
use super::super::crypto::SessionCrypto;
use super::super::payloads::HDR_SIZE;
use super::RawPacket;
use super::parse_header;
const ENCRYPTED_FLAG: u16 = 1 << 1;
const KNOWN_FLAGS: u16 = ENCRYPTED_FLAG;
pub async fn read_encrypted(stream: &mut TcpStream, crypto: &SessionCrypto) -> Result<RawPacket> {
let mut buf = [0u8; HDR_SIZE];
stream.read_exact(&mut buf).await?;
let (packet_id, flags, sequence, payload_length) = parse_header(&buf)?;
if flags & !KNOWN_FLAGS != 0 {
return Err(anyhow!("unknown packet flags: 0x{:04X}", flags));
}
if flags & ENCRYPTED_FLAG == 0 {
return Err(anyhow!("packet not encrypted"));
}
let mut encrypted = vec![0u8; payload_length as usize];
if !encrypted.is_empty() {
stream.read_exact(&mut encrypted).await?;
}
let payload = crypto.decrypt_s2c(sequence as u64, &encrypted)?;
Ok(RawPacket { packet_id, payload })
}
pub async fn write_encrypted(
stream: &mut TcpStream,
id: u16,
seq: &mut u32,
payload: &[u8],
crypto: &SessionCrypto,
) -> Result<()> {
let encrypted = crypto.encrypt_c2s(*seq as u64, payload);
let mut hdr = [0u8; HDR_SIZE];
hdr[0..2].copy_from_slice(&id.to_be_bytes());
hdr[2..4].copy_from_slice(&ENCRYPTED_FLAG.to_be_bytes());
hdr[4..8].copy_from_slice(&seq.to_be_bytes());
hdr[8..12].copy_from_slice(&(encrypted.len() as u32).to_be_bytes());
*seq = seq.wrapping_add(1);
let mut frame = Vec::with_capacity(HDR_SIZE + encrypted.len());
frame.extend_from_slice(&hdr);
frame.extend_from_slice(&encrypted);
stream.write_all(&frame).await?;
Ok(())
}

101
src/net/framing/mod.rs Normal file
View file

@ -0,0 +1,101 @@
use anyhow::{Result, anyhow};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use super::payloads::{HDR_SIZE, MAX_PAYLOAD};
pub struct RawPacket {
pub packet_id: u16,
pub payload: Vec<u8>,
}
pub(super) fn parse_header(buf: &[u8; HDR_SIZE]) -> Result<(u16, u16, u32, u32)> {
let packet_id = u16::from_be_bytes([buf[0], buf[1]]);
let flags = u16::from_be_bytes([buf[2], buf[3]]);
let sequence = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]);
let payload_length = u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]);
if payload_length > MAX_PAYLOAD {
return Err(anyhow!("payload too large: {payload_length}"));
}
Ok((packet_id, flags, sequence, payload_length))
}
pub fn encode_frame(id: u16, seq: &mut u32, payload: &[u8]) -> Vec<u8> {
let mut hdr = [0u8; HDR_SIZE];
hdr[0..2].copy_from_slice(&id.to_be_bytes());
hdr[4..8].copy_from_slice(&seq.to_be_bytes());
hdr[8..12].copy_from_slice(&(payload.len() as u32).to_be_bytes());
*seq = seq.wrapping_add(1);
let mut frame = Vec::with_capacity(HDR_SIZE + payload.len());
frame.extend_from_slice(&hdr);
frame.extend_from_slice(payload);
frame
}
pub fn decode_frame(bytes: &[u8]) -> Result<RawPacket> {
if bytes.len() < HDR_SIZE {
return Err(anyhow!("frame too short"));
}
let hdr: [u8; HDR_SIZE] = bytes[..HDR_SIZE].try_into().expect("header length");
let (packet_id, _, _, payload_length) = parse_header(&hdr)?;
let end = HDR_SIZE + payload_length as usize;
if bytes.len() < end {
return Err(anyhow!("truncated frame"));
}
Ok(RawPacket {
packet_id,
payload: bytes[HDR_SIZE..end].to_vec(),
})
}
pub async fn read(stream: &mut TcpStream) -> Result<RawPacket> {
let mut buf = [0u8; HDR_SIZE];
stream.read_exact(&mut buf).await?;
let (packet_id, _, _, payload_length) = parse_header(&buf)?;
let mut payload = vec![0u8; payload_length as usize];
if !payload.is_empty() {
stream.read_exact(&mut payload).await?;
}
Ok(RawPacket { packet_id, payload })
}
pub async fn write(stream: &mut TcpStream, id: u16, seq: &mut u32, payload: &[u8]) -> Result<()> {
let frame = encode_frame(id, seq, payload);
stream.write_all(&frame).await?;
Ok(())
}
pub mod io;
#[cfg(test)]
mod tests {
use super::*;
use crate::net::payloads::PID_PING;
#[test]
fn encode_decode_roundtrip() {
let payload = br#"{"ping":true}"#;
let mut seq = 7u32;
let frame = encode_frame(PID_PING, &mut seq, payload);
assert_eq!(seq, 8);
let pkt = decode_frame(&frame).unwrap();
assert_eq!(pkt.packet_id, PID_PING);
assert_eq!(pkt.payload, payload);
}
#[test]
fn decode_rejects_oversized_length() {
let mut hdr = [0u8; HDR_SIZE];
hdr[0..2].copy_from_slice(&PID_PING.to_be_bytes());
hdr[8..12].copy_from_slice(&(MAX_PAYLOAD + 1).to_be_bytes());
assert!(decode_frame(&hdr).is_err());
}
#[test]
fn decode_rejects_truncated_body() {
let mut seq = 0u32;
let frame = encode_frame(PID_PING, &mut seq, b"x");
let truncated = &frame[..frame.len() - 1];
assert!(decode_frame(truncated).is_err());
}
}

78
src/net/handshake.rs Normal file
View file

@ -0,0 +1,78 @@
use anyhow::{Result, anyhow};
use tokio::net::TcpStream;
use tracing::{debug, info};
use super::crypto::SessionCrypto;
use super::framing;
use super::payloads::*;
use crate::identity::Identity;
/// Perform HELLO → AUTH → SESSION with X25519 ECDH key exchange.
///
/// Returns (session_id, node_name, private_mode, crypto).
/// All subsequent packets must be encrypted with `crypto`.
pub async fn run(
stream: &mut TcpStream,
identity: &Identity,
seq: &mut u32,
) -> Result<(String, String, bool, SessionCrypto)> {
// HELLO — receive server's ephemeral X25519 public key
let pkt = framing::read(stream).await?;
if pkt.packet_id != PID_HELLO {
return Err(anyhow!("expected HELLO, got 0x{:04X}", pkt.packet_id));
}
let hello: HelloPayload = serde_json::from_slice(&pkt.payload)?;
debug!("← HELLO node={}", hello.node_name);
// Generate our own ephemeral X25519 keypair
let (eph_sk, eph_pk) = SessionCrypto::new_ephemeral();
let eph_pk_hex = hex::encode(eph_pk.as_bytes());
// Parse server's ephemeral key from HELLO
let server_eph_raw =
hex::decode(&hello.server_eph_pubkey).map_err(|_| anyhow!("bad server_eph_pubkey hex"))?;
let server_eph_key: [u8; 32] = server_eph_raw
.try_into()
.map_err(|_| anyhow!("bad server_eph_pubkey length"))?;
let server_eph_pk = x25519_dalek::PublicKey::from(server_eph_key);
// AUTH — include our ephemeral public key
let nonce = hex::decode(&hello.challenge_nonce)?;
let sk = identity
.signing_key()
.ok_or_else(|| anyhow!("no signing key"))?;
use ed25519_dalek::Signer;
let sig = sk.sign(&nonce);
let auth = AuthPayload {
client_pubkey: identity.pubkey_hex.clone(),
nickname: identity.nickname.clone(),
lnex_version: LNEX_VERSION.into(),
signature: hex::encode(sig.to_bytes()),
client_eph_pubkey: eph_pk_hex,
};
framing::write(stream, PID_AUTH, seq, &serde_json::to_vec(&auth)?).await?;
debug!("→ AUTH nick={}", identity.nickname);
// SESSION
let pkt = framing::read(stream).await?;
if pkt.packet_id == PID_ERROR {
let e: ErrorPayload = serde_json::from_slice(&pkt.payload)?;
return Err(anyhow!("auth rejected: {}", e.message));
}
if pkt.packet_id != PID_SESSION {
return Err(anyhow!("expected SESSION, got 0x{:04X}", pkt.packet_id));
}
let sess: SessionPayload = serde_json::from_slice(&pkt.payload)?;
// Derive encryption keys from ECDH shared secret
let shared_secret = SessionCrypto::ecdh(eph_sk, &server_eph_pk);
let crypto = SessionCrypto::derive(shared_secret.as_bytes(), &sess.session_id);
info!(
"authenticated — session {} encrypted",
&sess.session_id[..8]
);
Ok((sess.session_id, hello.node_name, hello.private_mode, crypto))
}

View file

@ -0,0 +1,162 @@
use anyhow::{Result, anyhow};
use serde_json;
use tokio::sync::mpsc;
use super::super::super::payloads::*;
use super::super::super::types::NetEvent;
use super::super::super::types::{ChannelListItem as InfoChannelListItem, ChatMsg, MemberInfo};
pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) -> Result<()> {
match pid {
PID_CHANNEL_STATE => {
let p: ChannelStatePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::ChannelState {
channel_id: p.channel_id,
channel_name: p.channel_name,
kind: p.kind,
members: p
.members
.into_iter()
.map(|m| MemberInfo {
user_id: m.user_id,
nickname: m.nickname,
in_voice: m.in_voice,
})
.collect(),
voice_endpoint: p.voice_endpoint,
})
.await;
}
PID_CHANNEL_CREATE => {
let p: ChannelCreatePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::ChannelCreated {
channel_id: p.channel_id,
channel_name: p.channel_name,
kind: p.kind,
})
.await;
}
PID_CHANNEL_DELETE => {
let p: ChannelDeletePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::ChannelDeleted {
channel_id: p.channel_id,
})
.await;
}
PID_CHANNEL_LIST => {
let p: ChannelListPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::ChannelListEvent {
channels: p
.channels
.into_iter()
.map(|c| InfoChannelListItem {
channel_id: c.channel_id,
channel_name: c.channel_name,
kind: c.kind,
})
.collect(),
})
.await;
}
PID_CHAT_MESSAGE => {
let p: ChatMessagePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::ChatMessage {
message_id: p.message_id,
channel_id: p.channel_id,
sender_id: p.sender_id.clone(),
nickname: p.sender_id,
content: p.content,
timestamp: p.timestamp,
reply_to: p.reply_to,
})
.await;
}
PID_CHAT_HISTORY => {
let p: ChatHistoryPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::ChatHistory {
channel_id: p.channel_id,
messages: p
.messages
.into_iter()
.map(|m| ChatMsg {
message_id: m.message_id,
sender_id: m.sender_id,
content: m.content,
timestamp: m.timestamp,
reply_to: m.reply_to,
})
.collect(),
})
.await;
}
PID_USER_JOIN => {
let p: UserJoinPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::UserJoin {
channel_id: p.channel_id,
user_id: p.user_id,
nickname: p.nickname,
})
.await;
}
PID_USER_LEAVE => {
let p: UserLeavePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::UserLeave {
channel_id: p.channel_id,
user_id: p.user_id,
})
.await;
}
PID_PONG => {
let p: PongPayload = serde_json::from_slice(payload)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
let rtt_ms = (now - p.timestamp).max(0) as u32;
let _ = tx.send(NetEvent::LatencyUpdate { rtt_ms }).await;
}
PID_READ_RECEIPT_BROADCAST => {
let p: serde_json::Value = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::ReadReceiptBroadcast {
channel_id: p["channel_id"].as_str().unwrap_or_default().into(),
user_id: p["user_id"].as_str().unwrap_or_default().into(),
last_read_message_id: p["last_read_message_id"]
.as_str()
.unwrap_or_default()
.into(),
})
.await;
}
PID_ERROR => {
let p: ErrorPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::Error {
code: p.code,
message: p.message,
})
.await;
}
PID_DISCONNECT => return Err(anyhow!("server disconnected")),
PID_TYPING_START => {
let p: serde_json::Value = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::TypingStart {
user_id: p["user_id"].as_str().unwrap_or_default().into(),
nickname: p["nickname"].as_str().unwrap_or_default().into(),
channel_id: p["channel_id"].as_str().unwrap_or_default().into(),
})
.await;
}
_ => {}
}
Ok(())
}

View file

@ -0,0 +1,152 @@
use anyhow::Result;
use serde_json;
use tokio::sync::mpsc;
use tracing::debug;
use super::super::super::payloads::*;
use super::super::super::types::*;
pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) -> Result<()> {
match pid {
PID_DM_START => {
let p: DmStartResponsePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::DmStart {
dm_id: p.dm_id,
other_user_id: p.other_user_id,
other_nickname: p.other_nickname,
messages: p
.messages
.into_iter()
.map(|m| ChatMsg {
message_id: String::new(),
sender_id: m.sender_id,
content: m.content,
timestamp: m.timestamp,
reply_to: None,
})
.collect(),
unread_count: p.unread_count,
})
.await;
}
PID_DM_MESSAGE => {
let p: DmMessagePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::DmMessage {
dm_id: p.dm_id,
sender_id: p.sender_id,
content: p.content,
timestamp: p.timestamp,
})
.await;
}
PID_DM_HISTORY => {
let p: DmHistoryPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::DmHistory {
dm_id: p.dm_id,
messages: p
.messages
.into_iter()
.map(|m| ChatMsg {
message_id: String::new(),
sender_id: m.sender_id,
content: m.content,
timestamp: m.timestamp,
reply_to: None,
})
.collect(),
})
.await;
}
PID_FRIEND_LIST => {
let p: FriendListPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::FriendList {
friends: p
.friends
.into_iter()
.map(|f| FriendInfo {
user_id: f.user_id,
nickname: f.nickname,
status: f.status,
since: f.since,
})
.collect(),
})
.await;
}
PID_MESSAGE_REACTION_ADD => {
let p: ReactionPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::ReactionAdded {
channel_id: p.channel_id,
message_id: p.message_id,
user_id: p.user_id,
emoji: p.emoji,
})
.await;
}
PID_MESSAGE_REACTION_REMOVE => {
let p: ReactionPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::ReactionRemoved {
channel_id: p.channel_id,
message_id: p.message_id,
user_id: p.user_id,
emoji: p.emoji,
})
.await;
}
PID_MESSAGE_EDIT => {
let p: ChatMessagePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::MessageEdited {
channel_id: p.channel_id,
message_id: p.message_id,
content: p.content,
edited: p.edited,
})
.await;
}
PID_MESSAGE_DELETE => {
let p: MessageDeletePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::MessageDeleted {
channel_id: p.channel_id,
message_id: p.message_id,
})
.await;
}
PID_FRIEND_EVENT => {
let p: FriendEventPayload = serde_json::from_slice(payload)?;
match p.event_type.as_str() {
"request" => {
let _ = tx
.send(NetEvent::FriendRequested {
user_id: p.user_id,
nickname: p.nickname.unwrap_or_default(),
})
.await;
}
"accepted" => {
let _ = tx
.send(NetEvent::FriendAccepted {
user_id: p.user_id,
nickname: p.nickname.unwrap_or_default(),
})
.await;
}
"removed" => {
let _ = tx
.send(NetEvent::FriendRemoved { user_id: p.user_id })
.await;
}
_ => debug!("unhandled friend event: {}", p.event_type),
}
}
_ => {}
}
Ok(())
}

View file

@ -0,0 +1,53 @@
use anyhow::Result;
use tokio::sync::mpsc;
use tracing::debug;
use super::super::payloads::*;
use super::super::types::*;
mod channel;
mod dm;
mod social;
pub async fn incoming(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) -> Result<()> {
match pid {
PID_CHANNEL_STATE
| PID_CHAT_MESSAGE
| PID_CHAT_HISTORY
| PID_USER_JOIN
| PID_USER_LEAVE
| PID_PONG
| PID_READ_RECEIPT_BROADCAST
| PID_ERROR
| PID_DISCONNECT
| PID_TYPING_START => channel::handle(pid, payload, tx).await?,
PID_DM_START
| PID_DM_MESSAGE
| PID_DM_HISTORY
| PID_FRIEND_LIST
| PID_MESSAGE_REACTION_ADD
| PID_MESSAGE_REACTION_REMOVE
| PID_MESSAGE_EDIT
| PID_MESSAGE_DELETE
| PID_FRIEND_EVENT => dm::handle(pid, payload, tx).await?,
PID_GUILD_LIST
| PID_GUILD_CREATE
| PID_GUILD_DELETE
| PID_GUILD_MEMBER_JOIN
| PID_GUILD_MEMBER_LEAVE
| PID_GUILD_MEMBER_KICK
| PID_INVITE_CREATE
| PID_INVITE_ACCEPT
| PID_INVITE_DELETE
| PID_ROLE_CREATE
| PID_ROLE_DELETE
| PID_USER_ROLE_UPDATE
| PID_PRESENCE_SYNC
| PID_PRESENCE_EVENT => social::handle(pid, payload, tx).await?,
other => debug!("unhandled 0x{:04X}", other),
}
Ok(())
}

View file

@ -0,0 +1,232 @@
use anyhow::Result;
use serde_json;
use tokio::sync::mpsc;
use super::super::super::payloads::*;
use super::super::super::types::*;
pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) -> Result<()> {
match pid {
PID_GUILD_LIST => {
let p: GuildListPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::GuildList {
guilds: p
.guilds
.into_iter()
.map(|g| GuildInfo {
guild_id: g.guild_id,
name: g.name,
owner_id: g.owner_id,
icon_url: g.icon_url,
member_count: g.member_count,
})
.collect(),
})
.await;
}
PID_GUILD_CREATE => {
let p: GuildInfoPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::GuildCreated {
guild_id: p.guild_id,
name: p.name,
owner_id: p.owner_id,
})
.await;
}
PID_GUILD_DELETE => {
let p: GuildDeletePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::GuildDeleted {
guild_id: p.guild_id,
})
.await;
}
PID_GUILD_MEMBER_JOIN => {
let p: serde_json::Value = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::GuildMemberJoined {
guild_id: p["guild_id"].as_str().unwrap_or_default().into(),
user_id: p["user_id"].as_str().unwrap_or_default().into(),
nickname: p["nickname"].as_str().unwrap_or_default().into(),
})
.await;
}
PID_GUILD_MEMBER_LEAVE => {
let p: serde_json::Value = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::GuildMemberLeft {
guild_id: p["guild_id"].as_str().unwrap_or_default().into(),
user_id: p["user_id"].as_str().unwrap_or_default().into(),
})
.await;
}
PID_USER_ROLE_UPDATE => {
let p: UserRoleUpdatePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::UserColor {
user_id: p.user_id,
color: p.color,
})
.await;
}
PID_PRESENCE_SYNC => {
let p: PresenceSyncPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::PresenceSync {
presences: p
.presences
.into_iter()
.map(|p| PresenceInfo {
user_id: p.user_id,
status: p.status,
custom_status: p.custom_status,
activity: p.activity,
activity_text: p.activity_text,
})
.collect(),
})
.await;
}
PID_PRESENCE_EVENT => {
let p: PresenceEventPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::PresenceUpdated {
user_id: p.user_id,
status: p.status,
custom_status: p.custom_status,
activity: p.activity,
})
.await;
}
PID_GUILD_MEMBER_KICK => {
let p: serde_json::Value = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::GuildMemberKicked {
guild_id: p["guild_id"].as_str().unwrap_or_default().into(),
user_id: p["user_id"].as_str().unwrap_or_default().into(),
})
.await;
}
PID_INVITE_CREATE => {
let p: InviteInfoPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::InviteCreated {
id: p.id,
guild_id: p.guild_id,
guild_name: p.guild_name,
code: p.code,
creator_id: p.creator_id,
max_uses: p.max_uses,
uses: p.uses,
expires_at: p.expires_at,
created_at: p.created_at,
})
.await;
}
PID_INVITE_ACCEPT => {
let p: serde_json::Value = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::InviteAccepted {
guild_id: p["guild_id"].as_str().unwrap_or_default().into(),
guild_name: p["guild_name"].as_str().unwrap_or_default().into(),
})
.await;
}
PID_INVITE_DELETE => {
let p: InviteDeletePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::InviteDeleted {
guild_id: p.guild_id,
invite_id: p.invite_id,
})
.await;
}
PID_ROLE_CREATE => {
let p: serde_json::Value = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::RoleCreated {
role_id: p["id"].as_str().unwrap_or_default().into(),
guild_id: p["guild_id"].as_str().unwrap_or_default().into(),
name: p["name"].as_str().unwrap_or_default().into(),
})
.await;
}
PID_ROLE_DELETE => {
let p: RoleDeletePayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::RoleDeleted {
role_id: p.role_id,
guild_id: p.guild_id,
})
.await;
}
PID_GUILD_AUDIT_LOG => {
let p: GuildAuditLogPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::GuildAuditLog {
guild_id: p.guild_id,
entries: p.entries,
})
.await;
}
PID_GUILD_MEMBER_LIST => {
let p: GuildMemberListPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::GuildMemberList {
guild_id: p.guild_id,
members: p.members,
})
.await;
}
PID_GUILD_ROLE_LIST => {
let p: GuildRoleListPayload = serde_json::from_slice(payload)?;
let _ = tx
.send(NetEvent::GuildRoleList {
guild_id: p.guild_id,
roles: p.roles,
})
.await;
}
PID_BLOCK_LIST => {
// Server may send {"blocked": [...]} — try to parse properly first,
// fall back to JSON value extraction.
let blocked: Vec<String> = match serde_json::from_slice::<BlockListPayload>(payload) {
Ok(p) => p.blocked,
Err(_) => {
let v: serde_json::Value = serde_json::from_slice(payload)?;
v["blocked"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
};
let _ = tx.send(NetEvent::BlockList { blocked }).await;
}
PID_BLOCK_USER => {
let v: serde_json::Value = serde_json::from_slice(payload).unwrap_or_default();
let user_id = v["user_id"]
.as_str()
.or_else(|| v["blocked"].as_str())
.unwrap_or_default()
.to_string();
let _ = tx.send(NetEvent::BlockedUser { user_id }).await;
}
PID_UNBLOCK_USER => {
let v: serde_json::Value = serde_json::from_slice(payload).unwrap_or_default();
let user_id = v["user_id"]
.as_str()
.or_else(|| v["unblocked"].as_str())
.unwrap_or_default()
.to_string();
let _ = tx.send(NetEvent::UnblockedUser { user_id }).await;
}
_ => {}
}
Ok(())
}

1
src/net/internal/mod.rs Normal file
View file

@ -0,0 +1 @@
pub(super) mod dispatch;

43
src/net/mod.rs Normal file
View file

@ -0,0 +1,43 @@
pub mod crypto;
pub mod framing;
pub mod handshake;
mod internal;
pub mod payloads;
mod session;
mod types;
pub mod voice;
pub use types::{ChatMsg, FriendInfo, GuildInfo, MemberInfo, NetCommand, NetEvent, PresenceInfo};
use std::sync::Arc;
use std::sync::Mutex;
use tokio::sync::mpsc;
use crate::identity::Identity;
// ─── Handle ──────────────────────────────────────────────────────────────────
#[derive(Clone)]
pub struct NetHandle {
cmd_tx: mpsc::Sender<NetCommand>,
event_rx: Arc<Mutex<mpsc::Receiver<NetEvent>>>,
}
impl NetHandle {
pub async fn send(&self, cmd: NetCommand) {
let _ = self.cmd_tx.send(cmd).await;
}
pub fn try_recv(&self) -> Option<NetEvent> {
self.event_rx.try_lock().ok()?.try_recv().ok()
}
}
pub fn spawn(identity: Identity) -> NetHandle {
let (cmd_tx, cmd_rx) = mpsc::channel::<NetCommand>(64);
let (event_tx, event_rx) = mpsc::channel::<NetEvent>(256);
tokio::spawn(session::net_task(identity, cmd_rx, event_tx));
NetHandle {
cmd_tx,
event_rx: Arc::new(Mutex::new(event_rx)),
}
}

152
src/net/payloads/channel.rs Normal file
View file

@ -0,0 +1,152 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct HelloPayload {
pub lnex_version: String,
pub server_pubkey: String,
pub challenge_nonce: String,
pub node_name: String,
pub server_eph_pubkey: String,
#[serde(default)]
pub private_mode: bool,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct AuthPayload {
pub client_pubkey: String,
pub nickname: String,
pub lnex_version: String,
pub signature: String,
pub client_eph_pubkey: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SessionPayload {
pub session_id: String,
pub token: String,
pub expires_at: i64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct PingPayload {
pub timestamp: i64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct PongPayload {
pub timestamp: i64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct JoinChannelPayload {
pub channel_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct LeaveChannelPayload {
pub channel_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ChannelStatePayload {
pub channel_id: String,
pub channel_name: String,
pub kind: String,
pub members: Vec<WireMember>,
pub voice_endpoint: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ChannelCreatePayload {
pub channel_id: String,
pub channel_name: String,
/// "text" or "voice".
pub kind: String,
#[serde(default)]
pub guild_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ChannelDeletePayload {
pub channel_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ChannelListPayload {
pub channels: Vec<ChannelListItem>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ChannelListItem {
pub channel_id: String,
pub channel_name: String,
pub kind: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct WireMember {
pub user_id: String,
pub nickname: String,
pub in_voice: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ChatMessagePayload {
pub message_id: String,
pub channel_id: String,
pub sender_id: String,
pub content: String,
pub timestamp: i64,
#[serde(default)]
pub edited: bool,
/// Optional message_id this message is replying to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_to: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ChatHistoryPayload {
pub channel_id: String,
pub messages: Vec<ChatMessagePayload>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ReactionPayload {
pub message_id: String,
pub channel_id: String,
pub emoji: String,
#[serde(default)]
pub user_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct MessageEditPayload {
pub message_id: String,
pub channel_id: String,
pub content: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct MessageDeletePayload {
pub message_id: String,
pub channel_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct UserJoinPayload {
pub channel_id: String,
pub user_id: String,
pub nickname: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct UserLeavePayload {
pub channel_id: String,
pub user_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ErrorPayload {
pub code: u32,
pub message: String,
}

133
src/net/payloads/dm.rs Normal file
View file

@ -0,0 +1,133 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct DmStartPayload {
pub target_user_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DmStartResponsePayload {
pub dm_id: String,
pub other_user_id: String,
pub other_nickname: String,
pub messages: Vec<DmMessagePayload>,
#[serde(default)]
pub unread_count: u32,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DmMessagePayload {
pub dm_id: String,
pub sender_id: String,
pub content: String,
pub timestamp: i64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DmHistoryPayload {
pub dm_id: String,
#[serde(default)]
pub messages: Vec<DmMessagePayload>,
#[serde(default)]
pub search_query: Option<String>,
#[serde(default)]
pub limit: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct PresenceUpdatePayload {
pub status: String,
/// Free-form activity type ("playing", "listening", "watching", "streaming").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activity_type: Option<String>,
/// Free-form activity text (e.g. "CS2 ranked").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activity_text: Option<String>,
/// Free-form custom status text (Discord-style "what's on your mind?").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_status: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PresenceInfoPayload {
pub user_id: String,
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activity_text: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct PresenceSyncPayload {
pub presences: Vec<PresenceInfoPayload>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct PresenceEventPayload {
pub user_id: String,
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activity_text: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct FriendRequestPayload {
pub target_user_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct FriendAcceptPayload {
pub user_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct FriendDeclinePayload {
pub user_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct FriendRemovePayload {
pub user_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FriendInfoPayload {
pub user_id: String,
pub nickname: String,
pub status: String,
pub since: i64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct FriendListPayload {
pub friends: Vec<FriendInfoPayload>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct BlockUserPayload {
pub user_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct UnblockUserPayload {
pub user_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct BlockListPayload {
pub blocked: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct FriendEventPayload {
pub event_type: String,
pub user_id: String,
pub nickname: Option<String>,
}

176
src/net/payloads/guild.rs Normal file
View file

@ -0,0 +1,176 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct GuildCreatePayload {
pub name: String,
pub icon_url: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GuildInfoPayload {
pub guild_id: String,
pub name: String,
pub owner_id: String,
pub icon_url: Option<String>,
pub member_count: u32,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GuildListPayload {
pub guilds: Vec<GuildInfoPayload>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GuildDeletePayload {
pub guild_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GuildJoinPayload {
pub guild_id: String,
pub invite_code: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GuildLeavePayload {
pub guild_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct UserRoleUpdatePayload {
pub user_id: String,
pub guild_id: String,
pub color: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct InviteCreatePayload {
pub guild_id: String,
pub max_uses: Option<i64>,
pub expires_in_seconds: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct InviteAcceptPayload {
pub code: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct InviteDeletePayload {
pub guild_id: String,
pub invite_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct RoleCreatePayload {
pub guild_id: String,
pub name: String,
pub color: Option<String>,
pub permissions: Option<u64>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct RoleDeletePayload {
pub guild_id: String,
pub role_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GuildMemberKickPayload {
pub guild_id: String,
pub user_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct InviteInfoPayload {
pub id: String,
pub guild_id: String,
pub guild_name: String,
pub code: String,
pub creator_id: String,
pub max_uses: Option<i64>,
pub uses: i64,
pub expires_at: Option<i64>,
pub created_at: i64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GuildAuditLogFetchPayload {
pub guild_id: String,
#[serde(default = "default_audit_limit")]
pub limit: i64,
}
fn default_audit_limit() -> i64 {
50
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AuditLogEntryPayload {
pub id: String,
pub guild_id: String,
pub actor_id: String,
pub action: String,
#[serde(default)]
pub target_id: Option<String>,
#[serde(default)]
pub target_type: Option<String>,
#[serde(default)]
pub reason: Option<String>,
pub created_at: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GuildAuditLogPayload {
pub guild_id: String,
pub entries: Vec<AuditLogEntryPayload>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GuildMemberListFetchPayload {
pub guild_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GuildMemberInfoPayload {
pub user_id: String,
pub nickname: String,
pub joined_at: i64,
pub role_color: String,
pub role_name: String,
pub is_owner: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GuildMemberListPayload {
pub guild_id: String,
pub members: Vec<GuildMemberInfoPayload>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct RoleAssignPayload {
pub guild_id: String,
pub user_id: String,
pub role_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GuildRoleListFetchPayload {
pub guild_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GuildRoleInfoPayload {
pub id: String,
pub guild_id: String,
pub name: String,
pub color: String,
pub permissions: u64,
pub position: i32,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GuildRoleListPayload {
pub guild_id: String,
pub roles: Vec<GuildRoleInfoPayload>,
}

76
src/net/payloads/mod.rs Normal file
View file

@ -0,0 +1,76 @@
/// Wire payload types and packet ID constants.
pub const PID_HELLO: u16 = 0x0001;
pub const PID_AUTH: u16 = 0x0002;
pub const PID_SESSION: u16 = 0x0003;
pub const PID_PING: u16 = 0x0004;
pub const PID_PONG: u16 = 0x0005;
pub const PID_VOICE_PACKET: u16 = 0x0010;
pub const PID_CHAT_MESSAGE: u16 = 0x0020;
pub const PID_CHAT_HISTORY: u16 = 0x0021;
pub const PID_JOIN_CHANNEL: u16 = 0x0030;
pub const PID_LEAVE_CHANNEL: u16 = 0x0031;
pub const PID_CHANNEL_STATE: u16 = 0x0032;
pub const PID_CHANNEL_CREATE: u16 = 0x0033;
pub const PID_CHANNEL_DELETE: u16 = 0x0034;
pub const PID_CHANNEL_LIST: u16 = 0x0035;
pub const PID_USER_JOIN: u16 = 0x0040;
pub const PID_USER_LEAVE: u16 = 0x0041;
pub const PID_DM_START: u16 = 0x0060;
pub const PID_DM_MESSAGE: u16 = 0x0061;
pub const PID_DM_HISTORY: u16 = 0x0062;
pub const PID_DM_READ_ACK: u16 = 0x0063;
pub const PID_MESSAGE_REACTION_ADD: u16 = 0x0066;
pub const PID_MESSAGE_REACTION_REMOVE: u16 = 0x0067;
pub const PID_MESSAGE_EDIT: u16 = 0x0068;
pub const PID_MESSAGE_DELETE: u16 = 0x0069;
pub const PID_READ_RECEIPT: u16 = 0x0064;
pub const PID_READ_RECEIPT_BROADCAST: u16 = 0x0065;
pub const PID_TYPING_START: u16 = 0x0070;
pub const PID_GUILD_CREATE: u16 = 0x0100;
pub const PID_GUILD_DELETE: u16 = 0x0101;
pub const PID_GUILD_LIST: u16 = 0x0102;
pub const PID_GUILD_MEMBER_JOIN: u16 = 0x0103;
pub const PID_GUILD_MEMBER_LEAVE: u16 = 0x0104;
pub const PID_GUILD_MEMBER_KICK: u16 = 0x0105;
pub const PID_ROLE_CREATE: u16 = 0x0106;
pub const PID_ROLE_DELETE: u16 = 0x0107;
pub const PID_INVITE_CREATE: u16 = 0x0108;
pub const PID_INVITE_ACCEPT: u16 = 0x0109;
pub const PID_INVITE_DELETE: u16 = 0x010A;
pub const PID_USER_ROLE_UPDATE: u16 = 0x010B;
pub const PID_GUILD_AUDIT_LOG_FETCH: u16 = 0x010C;
pub const PID_GUILD_AUDIT_LOG: u16 = 0x010D;
pub const PID_GUILD_MEMBER_LIST_FETCH: u16 = 0x010E;
pub const PID_GUILD_MEMBER_LIST: u16 = 0x010F;
pub const PID_GUILD_ROLE_ASSIGN: u16 = 0x0110;
pub const PID_GUILD_ROLE_UNASSIGN: u16 = 0x0111;
pub const PID_GUILD_ROLE_LIST_FETCH: u16 = 0x0112;
pub const PID_GUILD_ROLE_LIST: u16 = 0x0113;
pub const PID_PERMISSION_CHECK: u16 = 0x0050;
pub const PID_PERMISSION_DENY: u16 = 0x0051;
pub const PID_PRESENCE_UPDATE: u16 = 0x0140;
pub const PID_PRESENCE_SYNC: u16 = 0x0141;
pub const PID_PRESENCE_EVENT: u16 = 0x0142;
pub const PID_FRIEND_REQUEST: u16 = 0x0150;
pub const PID_FRIEND_ACCEPT: u16 = 0x0151;
pub const PID_FRIEND_DECLINE: u16 = 0x0152;
pub const PID_FRIEND_REMOVE: u16 = 0x0153;
pub const PID_FRIEND_LIST: u16 = 0x0154;
pub const PID_BLOCK_USER: u16 = 0x0155;
pub const PID_UNBLOCK_USER: u16 = 0x0156;
pub const PID_BLOCK_LIST: u16 = 0x0157;
pub const PID_FRIEND_EVENT: u16 = 0x0158;
pub const PID_ERROR: u16 = 0x00F0;
pub const PID_DISCONNECT: u16 = 0x00FF;
pub const LNEX_VERSION: &str = "v1";
pub const HDR_SIZE: usize = 12;
pub const MAX_PAYLOAD: u32 = 4 * 1024 * 1024;
mod channel;
mod dm;
mod guild;
pub use channel::*;
pub use dm::*;
pub use guild::*;

View file

@ -0,0 +1,167 @@
use std::time::Duration;
use anyhow::Result;
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::time::{MissedTickBehavior, interval};
use tracing::warn;
use crate::identity::Identity;
use crate::net::{
framing, handshake, internal,
payloads::*,
types::{NetCommand, NetEvent},
};
use serde_json;
use super::super::switch::ReconnectRequested;
use super::{VoiceState, handle_cmd};
pub async fn session_loop(
identity: &Identity,
address: &str,
channels: &mut Vec<String>,
restored: bool,
cmd_rx: &mut mpsc::Receiver<NetCommand>,
event_tx: &mpsc::Sender<NetEvent>,
) -> Result<()> {
let mut stream = TcpStream::connect(address).await?;
let mut seq = 0u32;
let (session_id, node_name, private_mode, crypto) =
handshake::run(&mut stream, identity, &mut seq).await?;
let _ = event_tx
.send(NetEvent::Connected {
session_id: session_id.clone(),
node_name,
restored,
private_mode,
})
.await;
for channel_id in channels.clone() {
framing::io::write_encrypted(
&mut stream,
PID_JOIN_CHANNEL,
&mut seq,
&serde_json::to_vec(&JoinChannelPayload { channel_id })?,
&crypto,
)
.await?;
}
let mut voice_state = VoiceState::new(&crypto, event_tx).await?;
let mut voice_endpoint: Option<std::net::SocketAddr> = None;
let mut ping_tick = interval(Duration::from_secs(2));
ping_tick.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = ping_tick.tick() => {
let ts = now_ms();
let payload = serde_json::to_vec(&PingPayload { timestamp: ts })?;
framing::io::write_encrypted(&mut stream, PID_PING, &mut seq, &payload, &crypto).await?;
}
result = framing::io::read_encrypted(&mut stream, &crypto) => {
let pkt = result?;
if pkt.packet_id == PID_CHANNEL_STATE
&& let Ok(p) = serde_json::from_slice::<ChannelStatePayload>(&pkt.payload)
{
voice_endpoint = if p.voice_endpoint.is_empty() {
None
} else {
p.voice_endpoint.parse().ok()
};
}
internal::dispatch::incoming(pkt.packet_id, &pkt.payload, event_tx).await?;
}
cmd = cmd_rx.recv() => {
match cmd {
None | Some(NetCommand::Disconnect) => {
let _ = framing::io::write_encrypted(&mut stream, PID_DISCONNECT, &mut seq,
&serde_json::to_vec(&serde_json::json!({"reason":"bye"})).unwrap(), &crypto).await;
return Ok(());
}
Some(NetCommand::Connect { address, auto_reconnect }) => {
warn!("connect while connected to {address}, reconnecting");
if channels.is_empty() {
*channels = vec!["general".into()];
}
return Err(anyhow::Error::new(ReconnectRequested {
address,
auto_reconnect,
}));
}
Some(NetCommand::JoinChannel { channel_id }) => {
track_channel(channels, &channel_id);
framing::io::write_encrypted(&mut stream, PID_JOIN_CHANNEL, &mut seq,
&serde_json::to_vec(&JoinChannelPayload { channel_id })?, &crypto).await?;
}
Some(NetCommand::LeaveChannel { channel_id }) => {
channels.retain(|c| c != &channel_id);
framing::io::write_encrypted(&mut stream, PID_LEAVE_CHANNEL, &mut seq,
&serde_json::to_vec(&LeaveChannelPayload { channel_id })?, &crypto).await?;
}
Some(NetCommand::ChannelCreate { channel_id, channel_name, kind }) => {
let payload = serde_json::to_vec(&ChannelCreatePayload {
channel_id,
channel_name,
kind,
guild_id: None,
})?;
framing::io::write_encrypted(&mut stream, PID_CHANNEL_CREATE, &mut seq, &payload, &crypto).await?;
}
Some(NetCommand::ChannelDelete { channel_id }) => {
let payload = serde_json::to_vec(&ChannelDeletePayload { channel_id })?;
framing::io::write_encrypted(&mut stream, PID_CHANNEL_DELETE, &mut seq, &payload, &crypto).await?;
}
Some(NetCommand::ChannelList) => {
framing::io::write_encrypted(&mut stream, PID_CHANNEL_LIST, &mut seq, b"{}", &crypto).await?;
}
Some(NetCommand::SendChat { channel_id, content, reply_to }) => {
let msg = ChatMessagePayload {
message_id: uuid_v4(), channel_id,
sender_id: identity.pubkey_hex.clone(),
content, timestamp: now_ms(),
edited: false,
reply_to,
};
framing::io::write_encrypted(&mut stream, PID_CHAT_MESSAGE, &mut seq, &serde_json::to_vec(&msg)?, &crypto).await?;
}
Some(NetCommand::SendVoice { channel_id, voice_seq: voice_pkt_seq, timestamp, opus_data }) => {
let sender_pubkey = identity.verifying_key()
.map(|k| k.to_bytes())
.unwrap_or([0u8; 32]);
voice_state.send_voice(channel_id, voice_pkt_seq, timestamp, &opus_data, &crypto, voice_endpoint, &sender_pubkey).await?;
}
Some(NetCommand::ReadReceipt { channel_id, last_read_message_id }) => {
let payload = serde_json::to_vec(&serde_json::json!({
"channel_id": channel_id,
"last_read_message_id": last_read_message_id,
"user_id": identity.pubkey_hex.clone(),
}))?;
framing::io::write_encrypted(&mut stream, PID_READ_RECEIPT, &mut seq, &payload, &crypto).await?;
}
Some(other) => handle_cmd(other, &mut stream, &mut seq, &crypto, identity).await?,
}
}
}
}
}
pub(super) fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
pub(super) fn uuid_v4() -> String {
uuid::Uuid::new_v4().to_string()
}
fn track_channel(channels: &mut Vec<String>, channel_id: &str) {
if !channels.iter().any(|c| c == channel_id) {
channels.push(channel_id.to_string());
}
}

View file

@ -0,0 +1,7 @@
mod core;
mod social;
mod voice;
pub use core::session_loop;
pub use social::handle_cmd;
pub use voice::State as VoiceState;

View file

@ -0,0 +1,62 @@
use anyhow::Result;
use serde_json;
use tokio::net::TcpStream;
use crate::net::{crypto::SessionCrypto, framing, payloads::*, types::NetCommand};
pub async fn handle_cmd(
cmd: NetCommand,
stream: &mut TcpStream,
seq: &mut u32,
crypto: &SessionCrypto,
) -> Result<()> {
match cmd {
NetCommand::PresenceUpdate {
status,
custom_status,
activity,
activity_text,
} => {
let payload = serde_json::to_vec(&PresenceUpdatePayload {
status,
activity_type: activity,
activity_text,
custom_status,
})?;
framing::io::write_encrypted(stream, PID_PRESENCE_UPDATE, seq, &payload, crypto)
.await?;
}
NetCommand::FriendRequest { target_user_id } => {
let payload = serde_json::to_vec(&FriendRequestPayload { target_user_id })?;
framing::io::write_encrypted(stream, PID_FRIEND_REQUEST, seq, &payload, crypto).await?;
}
NetCommand::FriendAccept { user_id } => {
let payload = serde_json::to_vec(&FriendAcceptPayload { user_id })?;
framing::io::write_encrypted(stream, PID_FRIEND_ACCEPT, seq, &payload, crypto).await?;
}
NetCommand::FriendDecline { user_id } => {
let payload = serde_json::to_vec(&FriendDeclinePayload { user_id })?;
framing::io::write_encrypted(stream, PID_FRIEND_DECLINE, seq, &payload, crypto).await?;
}
NetCommand::FriendRemove { user_id } => {
let payload = serde_json::to_vec(&FriendRemovePayload { user_id })?;
framing::io::write_encrypted(stream, PID_FRIEND_REMOVE, seq, &payload, crypto).await?;
}
NetCommand::FriendList => {
framing::io::write_encrypted(stream, PID_FRIEND_LIST, seq, b"{}", crypto).await?;
}
NetCommand::BlockUser { user_id } => {
let payload = serde_json::to_vec(&BlockUserPayload { user_id })?;
framing::io::write_encrypted(stream, PID_BLOCK_USER, seq, &payload, crypto).await?;
}
NetCommand::UnblockUser { user_id } => {
let payload = serde_json::to_vec(&UnblockUserPayload { user_id })?;
framing::io::write_encrypted(stream, PID_UNBLOCK_USER, seq, &payload, crypto).await?;
}
NetCommand::BlockList => {
framing::io::write_encrypted(stream, PID_BLOCK_LIST, seq, b"{}", crypto).await?;
}
_ => {}
}
Ok(())
}

View file

@ -0,0 +1,143 @@
use anyhow::Result;
use serde_json;
use tokio::net::TcpStream;
use crate::net::{crypto::SessionCrypto, framing, payloads::*, types::NetCommand};
pub async fn handle_cmd(
cmd: NetCommand,
stream: &mut TcpStream,
seq: &mut u32,
crypto: &SessionCrypto,
) -> Result<()> {
match cmd {
NetCommand::GuildCreate { name } => {
let payload = serde_json::to_vec(&GuildCreatePayload {
name,
icon_url: None,
})?;
framing::io::write_encrypted(stream, PID_GUILD_CREATE, seq, &payload, crypto).await?;
}
NetCommand::GuildDelete { guild_id } => {
let payload = serde_json::to_vec(&GuildDeletePayload { guild_id })?;
framing::io::write_encrypted(stream, PID_GUILD_DELETE, seq, &payload, crypto).await?;
}
NetCommand::GuildList => {
framing::io::write_encrypted(stream, PID_GUILD_LIST, seq, b"{}", crypto).await?;
}
NetCommand::GuildJoin {
guild_id,
invite_code,
} => {
let payload = serde_json::to_vec(&GuildJoinPayload {
guild_id,
invite_code,
})?;
framing::io::write_encrypted(stream, PID_GUILD_MEMBER_JOIN, seq, &payload, crypto)
.await?;
}
NetCommand::GuildLeave { guild_id } => {
let payload = serde_json::to_vec(&GuildLeavePayload { guild_id })?;
framing::io::write_encrypted(stream, PID_GUILD_MEMBER_LEAVE, seq, &payload, crypto)
.await?;
}
NetCommand::InviteCreate {
guild_id,
max_uses,
expires_in_seconds,
} => {
let payload = serde_json::to_vec(&InviteCreatePayload {
guild_id,
max_uses,
expires_in_seconds,
})?;
framing::io::write_encrypted(stream, PID_INVITE_CREATE, seq, &payload, crypto).await?;
}
NetCommand::InviteAccept { code } => {
let payload = serde_json::to_vec(&InviteAcceptPayload { code })?;
framing::io::write_encrypted(stream, PID_INVITE_ACCEPT, seq, &payload, crypto).await?;
}
NetCommand::InviteDelete {
guild_id,
invite_id,
} => {
let payload = serde_json::to_vec(&InviteDeletePayload {
guild_id,
invite_id,
})?;
framing::io::write_encrypted(stream, PID_INVITE_DELETE, seq, &payload, crypto).await?;
}
NetCommand::RoleCreate {
guild_id,
name,
color,
permissions,
} => {
let payload = serde_json::to_vec(&RoleCreatePayload {
guild_id,
name,
color,
permissions,
})?;
framing::io::write_encrypted(stream, PID_ROLE_CREATE, seq, &payload, crypto).await?;
}
NetCommand::RoleDelete { guild_id, role_id } => {
let payload = serde_json::to_vec(&RoleDeletePayload { guild_id, role_id })?;
framing::io::write_encrypted(stream, PID_ROLE_DELETE, seq, &payload, crypto).await?;
}
NetCommand::GuildMemberKick { guild_id, user_id } => {
let payload = serde_json::to_vec(&GuildMemberKickPayload { guild_id, user_id })?;
framing::io::write_encrypted(stream, PID_GUILD_MEMBER_KICK, seq, &payload, crypto)
.await?;
}
NetCommand::GuildAuditLogFetch { guild_id, limit } => {
let payload = serde_json::to_vec(&GuildAuditLogFetchPayload { guild_id, limit })?;
framing::io::write_encrypted(stream, PID_GUILD_AUDIT_LOG_FETCH, seq, &payload, crypto)
.await?;
}
NetCommand::GuildMemberListFetch { guild_id } => {
let payload = serde_json::to_vec(&GuildMemberListFetchPayload { guild_id })?;
framing::io::write_encrypted(
stream,
PID_GUILD_MEMBER_LIST_FETCH,
seq,
&payload,
crypto,
)
.await?;
}
NetCommand::GuildRoleAssign {
guild_id,
user_id,
role_id,
} => {
let payload = serde_json::to_vec(&RoleAssignPayload {
guild_id,
user_id,
role_id,
})?;
framing::io::write_encrypted(stream, PID_GUILD_ROLE_ASSIGN, seq, &payload, crypto)
.await?;
}
NetCommand::GuildRoleUnassign {
guild_id,
user_id,
role_id,
} => {
let payload = serde_json::to_vec(&RoleAssignPayload {
guild_id,
user_id,
role_id,
})?;
framing::io::write_encrypted(stream, PID_GUILD_ROLE_UNASSIGN, seq, &payload, crypto)
.await?;
}
NetCommand::GuildRoleListFetch { guild_id } => {
let payload = serde_json::to_vec(&GuildRoleListFetchPayload { guild_id })?;
framing::io::write_encrypted(stream, PID_GUILD_ROLE_LIST_FETCH, seq, &payload, crypto)
.await?;
}
_ => {}
}
Ok(())
}

View file

@ -0,0 +1,146 @@
mod friends;
mod guild;
use anyhow::Result;
use serde_json;
use tokio::net::TcpStream;
use crate::identity::Identity;
use crate::net::{crypto::SessionCrypto, framing, payloads::*, types::NetCommand};
use super::core::now_ms;
pub async fn handle_cmd(
cmd: NetCommand,
stream: &mut TcpStream,
seq: &mut u32,
crypto: &SessionCrypto,
identity: &Identity,
) -> Result<()> {
match cmd {
cmd @ (NetCommand::GuildCreate { .. }
| NetCommand::GuildDelete { .. }
| NetCommand::GuildList
| NetCommand::GuildJoin { .. }
| NetCommand::GuildLeave { .. }
| NetCommand::InviteCreate { .. }
| NetCommand::InviteAccept { .. }
| NetCommand::InviteDelete { .. }
| NetCommand::RoleCreate { .. }
| NetCommand::RoleDelete { .. }
| NetCommand::GuildMemberKick { .. }
| NetCommand::GuildAuditLogFetch { .. }
| NetCommand::GuildMemberListFetch { .. }
| NetCommand::GuildRoleAssign { .. }
| NetCommand::GuildRoleUnassign { .. }
| NetCommand::GuildRoleListFetch { .. }) => {
guild::handle_cmd(cmd, stream, seq, crypto).await?
}
cmd @ (NetCommand::PresenceUpdate { .. }
| NetCommand::FriendRequest { .. }
| NetCommand::FriendAccept { .. }
| NetCommand::FriendDecline { .. }
| NetCommand::FriendRemove { .. }
| NetCommand::FriendList
| NetCommand::BlockUser { .. }
| NetCommand::UnblockUser { .. }
| NetCommand::BlockList) => friends::handle_cmd(cmd, stream, seq, crypto).await?,
NetCommand::ReactionAdd {
channel_id,
message_id,
emoji,
} => {
let payload = serde_json::to_vec(&ReactionPayload {
message_id,
channel_id,
emoji,
user_id: String::new(),
})?;
framing::io::write_encrypted(stream, PID_MESSAGE_REACTION_ADD, seq, &payload, crypto)
.await?;
}
NetCommand::ReactionRemove {
channel_id,
message_id,
emoji,
} => {
let payload = serde_json::to_vec(&ReactionPayload {
message_id,
channel_id,
emoji,
user_id: String::new(),
})?;
framing::io::write_encrypted(
stream,
PID_MESSAGE_REACTION_REMOVE,
seq,
&payload,
crypto,
)
.await?;
}
NetCommand::MessageEdit {
channel_id,
message_id,
content,
} => {
let payload = serde_json::to_vec(&MessageEditPayload {
message_id,
channel_id,
content,
})?;
framing::io::write_encrypted(stream, PID_MESSAGE_EDIT, seq, &payload, crypto).await?;
}
NetCommand::MessageDelete {
channel_id,
message_id,
} => {
let payload = serde_json::to_vec(&MessageDeletePayload {
message_id,
channel_id,
})?;
framing::io::write_encrypted(stream, PID_MESSAGE_DELETE, seq, &payload, crypto).await?;
}
NetCommand::DmStart { target_user_id } => {
let payload = serde_json::to_vec(&DmStartPayload { target_user_id })?;
framing::io::write_encrypted(stream, PID_DM_START, seq, &payload, crypto).await?;
}
NetCommand::DmSend { dm_id, content } => {
let msg = DmMessagePayload {
dm_id,
sender_id: identity.pubkey_hex.clone(),
content,
timestamp: now_ms(),
};
framing::io::write_encrypted(
stream,
PID_DM_MESSAGE,
seq,
&serde_json::to_vec(&msg)?,
crypto,
)
.await?;
}
NetCommand::TypingStart { channel_id } => {
let payload = serde_json::to_vec(&serde_json::json!({"channel_id": channel_id}))?;
framing::io::write_encrypted(stream, PID_TYPING_START, seq, &payload, crypto).await?;
}
NetCommand::DmReadAck { dm_id } => {
let payload = serde_json::to_vec(&serde_json::json!({"dm_id": dm_id}))?;
framing::io::write_encrypted(stream, PID_DM_READ_ACK, seq, &payload, crypto).await?;
}
NetCommand::DmSearch { dm_id, query } => {
let payload = serde_json::to_vec(&DmHistoryPayload {
dm_id,
messages: vec![],
search_query: if query.is_empty() { None } else { Some(query) },
limit: None,
})?;
framing::io::write_encrypted(stream, PID_DM_HISTORY, seq, &payload, crypto).await?;
}
_ => {}
}
Ok(())
}

View file

@ -0,0 +1,47 @@
use std::sync::Arc;
use anyhow::Result;
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
use crate::net::{crypto::SessionCrypto, types::NetEvent};
pub struct State {
pub udp: Arc<UdpSocket>,
pub seq: u64,
}
impl State {
pub async fn new(crypto: &SessionCrypto, event_tx: &mpsc::Sender<NetEvent>) -> Result<Self> {
let udp = Arc::new(UdpSocket::bind("0.0.0.0:0").await?);
crate::net::voice::spawn_recv(udp.clone(), (*crypto).clone(), event_tx.clone());
Ok(Self { udp, seq: 0 })
}
#[allow(clippy::too_many_arguments)]
pub async fn send_voice(
&mut self,
channel_id: u64,
voice_pkt_seq: u32,
timestamp: u32,
opus_data: &[u8],
crypto: &SessionCrypto,
endpoint: Option<std::net::SocketAddr>,
sender_pubkey: &[u8; 32],
) -> Result<()> {
if let Some(ep) = endpoint {
let pkt = crate::net::voice::build_packet(
channel_id,
voice_pkt_seq,
timestamp,
opus_data,
crypto,
self.seq,
sender_pubkey,
);
let _ = self.udp.send_to(&pkt, ep).await;
self.seq += 1;
}
Ok(())
}
}

6
src/net/session/mod.rs Normal file
View file

@ -0,0 +1,6 @@
mod handlers;
mod reconnect;
mod switch;
mod task;
pub use task::net_task;

View file

@ -0,0 +1,24 @@
use std::time::Duration;
/// Max delay between reconnect attempts.
pub const MAX_BACKOFF_SECS: u64 = 30;
/// Exponential backoff after a failed session (1s, 2s, 4s, … capped at 30s).
pub fn backoff_delay(failure_count: u32) -> Duration {
let shift = failure_count.min(5);
let secs = (1u64 << shift).min(MAX_BACKOFF_SECS);
Duration::from_secs(secs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_grows_and_caps() {
assert_eq!(backoff_delay(0).as_secs(), 1);
assert_eq!(backoff_delay(1).as_secs(), 2);
assert_eq!(backoff_delay(3).as_secs(), 8);
assert_eq!(backoff_delay(10).as_secs(), MAX_BACKOFF_SECS);
}
}

20
src/net/session/switch.rs Normal file
View file

@ -0,0 +1,20 @@
/// User switched node while connected (`NetCommand::Connect`).
#[derive(Debug)]
pub struct ReconnectRequested {
pub address: String,
pub auto_reconnect: bool,
}
impl std::fmt::Display for ReconnectRequested {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "reconnect to {}", self.address)
}
}
impl std::error::Error for ReconnectRequested {}
pub struct SessionTarget {
pub address: String,
pub auto_reconnect: bool,
pub channels: Vec<String>,
}

116
src/net/session/task.rs Normal file
View file

@ -0,0 +1,116 @@
use tokio::sync::mpsc;
use tokio::time::sleep;
use tracing::{error, info};
use crate::identity::Identity;
use crate::net::types::{NetCommand, NetEvent};
use super::{
handlers::session_loop,
reconnect,
switch::{ReconnectRequested, SessionTarget},
};
pub async fn net_task(
identity: Identity,
mut cmd_rx: mpsc::Receiver<NetCommand>,
event_tx: mpsc::Sender<NetEvent>,
) {
let mut target: Option<SessionTarget> = None;
let mut failures: u32 = 0;
loop {
let Some(mut session) = target.take() else {
match wait_for_connect(&mut cmd_rx).await {
Some(t) => {
failures = 0;
target = Some(t);
}
None => return,
}
continue;
};
if failures > 0 {
let delay = reconnect::backoff_delay(failures - 1);
let _ = event_tx
.send(NetEvent::Reconnecting {
attempt: failures,
delay_secs: delay.as_secs(),
})
.await;
sleep(delay).await;
}
info!(
"connecting to {} (attempt {})",
session.address,
failures + 1
);
let restored = failures > 0;
match session_loop(
&identity,
&session.address,
&mut session.channels,
restored,
&mut cmd_rx,
&event_tx,
)
.await
{
Ok(()) => {
failures = 0;
let _ = event_tx
.send(NetEvent::Disconnected {
reason: "disconnected".into(),
will_retry: false,
})
.await;
}
Err(e) => {
if let Some(req) = e.downcast_ref::<ReconnectRequested>() {
info!("switching node to {}", req.address);
session.address = req.address.clone();
session.auto_reconnect = req.auto_reconnect;
failures = 0;
target = Some(session);
continue;
}
error!("session ended: {e}");
let retry = session.auto_reconnect;
let _ = event_tx
.send(NetEvent::Disconnected {
reason: e.to_string(),
will_retry: retry,
})
.await;
if retry {
failures = failures.saturating_add(1);
target = Some(session);
} else {
failures = 0;
}
}
}
}
}
async fn wait_for_connect(cmd_rx: &mut mpsc::Receiver<NetCommand>) -> Option<SessionTarget> {
loop {
match cmd_rx.recv().await {
Some(NetCommand::Connect {
address,
auto_reconnect,
}) => {
return Some(SessionTarget {
address,
auto_reconnect,
channels: vec!["general".into()],
});
}
Some(NetCommand::Disconnect) | None => return None,
_ => {}
}
}
}

162
src/net/types/command.rs Normal file
View file

@ -0,0 +1,162 @@
#[derive(Debug)]
pub enum NetCommand {
Connect {
address: String,
auto_reconnect: bool,
},
JoinChannel {
channel_id: String,
},
LeaveChannel {
channel_id: String,
},
ChannelCreate {
channel_id: String,
channel_name: String,
kind: String,
},
ChannelDelete {
channel_id: String,
},
ChannelList,
SendChat {
channel_id: String,
content: String,
/// Optional message_id this message is replying to.
reply_to: Option<String>,
},
SendVoice {
channel_id: u64,
voice_seq: u32,
timestamp: u32,
opus_data: Vec<u8>,
},
DmStart {
target_user_id: String,
},
DmSend {
dm_id: String,
content: String,
},
DmReadAck {
dm_id: String,
},
DmSearch {
dm_id: String,
query: String,
},
GuildCreate {
name: String,
},
GuildDelete {
guild_id: String,
},
GuildList,
GuildJoin {
guild_id: String,
invite_code: Option<String>,
},
GuildLeave {
guild_id: String,
},
InviteCreate {
guild_id: String,
max_uses: Option<i64>,
expires_in_seconds: Option<i64>,
},
InviteAccept {
code: String,
},
InviteDelete {
guild_id: String,
invite_id: String,
},
RoleCreate {
guild_id: String,
name: String,
color: Option<String>,
permissions: Option<u64>,
},
RoleDelete {
guild_id: String,
role_id: String,
},
GuildMemberKick {
guild_id: String,
user_id: String,
},
GuildAuditLogFetch {
guild_id: String,
limit: i64,
},
GuildMemberListFetch {
guild_id: String,
},
GuildRoleAssign {
guild_id: String,
user_id: String,
role_id: String,
},
GuildRoleUnassign {
guild_id: String,
user_id: String,
role_id: String,
},
GuildRoleListFetch {
guild_id: String,
},
PresenceUpdate {
status: String,
custom_status: Option<String>,
activity: Option<String>,
/// Free-form text describing the current activity (e.g. "playing CS2").
activity_text: Option<String>,
},
TypingStart {
channel_id: String,
},
ReadReceipt {
channel_id: String,
last_read_message_id: String,
},
FriendRequest {
target_user_id: String,
},
FriendAccept {
user_id: String,
},
FriendDecline {
user_id: String,
},
FriendRemove {
user_id: String,
},
FriendList,
BlockUser {
user_id: String,
},
UnblockUser {
user_id: String,
},
BlockList,
ReactionAdd {
channel_id: String,
message_id: String,
emoji: String,
},
ReactionRemove {
channel_id: String,
message_id: String,
emoji: String,
},
MessageEdit {
channel_id: String,
message_id: String,
content: String,
},
MessageDelete {
channel_id: String,
message_id: String,
},
Disconnect,
}

227
src/net/types/event.rs Normal file
View file

@ -0,0 +1,227 @@
use super::info::{ChannelListItem, ChatMsg, FriendInfo, GuildInfo, MemberInfo, PresenceInfo};
use std::net::SocketAddr;
#[derive(Debug, Clone)]
pub enum NetEvent {
Connected {
session_id: String,
node_name: String,
restored: bool,
private_mode: bool,
},
Disconnected {
reason: String,
will_retry: bool,
},
Reconnecting {
attempt: u32,
delay_secs: u64,
},
ChannelState {
channel_id: String,
channel_name: String,
kind: String,
members: Vec<MemberInfo>,
voice_endpoint: String,
},
ChannelCreated {
channel_id: String,
channel_name: String,
kind: String,
},
ChannelDeleted {
channel_id: String,
},
ChannelListEvent {
channels: Vec<ChannelListItem>,
},
ChatMessage {
message_id: String,
channel_id: String,
sender_id: String,
nickname: String,
content: String,
timestamp: i64,
/// Optional message_id this message is replying to.
reply_to: Option<String>,
},
ChatHistory {
channel_id: String,
messages: Vec<ChatMsg>,
},
UserJoin {
channel_id: String,
user_id: String,
nickname: String,
},
UserLeave {
channel_id: String,
user_id: String,
},
VoicePacket {
channel_id: u64,
voice_seq: u32,
timestamp: u32,
sender: SocketAddr,
/// Hex-encoded Ed25519 public key of the sender (Phase 1.3 attribution).
/// Empty for legacy packets that didn't include sender_id.
sender_id: String,
opus_data: Vec<u8>,
},
Error {
code: u32,
message: String,
},
LatencyUpdate {
rtt_ms: u32,
},
DmStart {
dm_id: String,
other_user_id: String,
other_nickname: String,
messages: Vec<ChatMsg>,
unread_count: u32,
},
DmMessage {
dm_id: String,
sender_id: String,
content: String,
timestamp: i64,
},
DmHistory {
dm_id: String,
messages: Vec<ChatMsg>,
},
TypingStart {
user_id: String,
nickname: String,
channel_id: String,
},
ReadReceiptBroadcast {
channel_id: String,
user_id: String,
last_read_message_id: String,
},
GuildList {
guilds: Vec<GuildInfo>,
},
GuildCreated {
guild_id: String,
name: String,
owner_id: String,
},
GuildDeleted {
guild_id: String,
},
GuildMemberJoined {
guild_id: String,
user_id: String,
nickname: String,
},
GuildMemberLeft {
guild_id: String,
user_id: String,
},
GuildMemberKicked {
guild_id: String,
user_id: String,
},
InviteCreated {
id: String,
guild_id: String,
guild_name: String,
code: String,
creator_id: String,
max_uses: Option<i64>,
uses: i64,
expires_at: Option<i64>,
created_at: i64,
},
InviteAccepted {
guild_id: String,
guild_name: String,
},
InviteDeleted {
guild_id: String,
invite_id: String,
},
RoleCreated {
role_id: String,
guild_id: String,
name: String,
},
RoleDeleted {
role_id: String,
guild_id: String,
},
GuildAuditLog {
guild_id: String,
entries: Vec<crate::net::payloads::AuditLogEntryPayload>,
},
GuildMemberList {
guild_id: String,
members: Vec<crate::net::payloads::GuildMemberInfoPayload>,
},
GuildRoleList {
guild_id: String,
roles: Vec<crate::net::payloads::GuildRoleInfoPayload>,
},
UserColor {
user_id: String,
color: String,
},
PresenceSync {
presences: Vec<PresenceInfo>,
},
PresenceUpdated {
user_id: String,
status: String,
custom_status: Option<String>,
activity: Option<String>,
},
FriendList {
friends: Vec<FriendInfo>,
},
FriendRequested {
user_id: String,
nickname: String,
},
FriendAccepted {
user_id: String,
nickname: String,
},
FriendRemoved {
user_id: String,
},
BlockList {
blocked: Vec<String>,
},
BlockedUser {
user_id: String,
},
UnblockedUser {
user_id: String,
},
ReactionAdded {
channel_id: String,
message_id: String,
user_id: String,
emoji: String,
},
ReactionRemoved {
channel_id: String,
message_id: String,
user_id: String,
emoji: String,
},
MessageEdited {
channel_id: String,
message_id: String,
content: String,
edited: bool,
},
MessageDeleted {
channel_id: String,
message_id: String,
},
}

49
src/net/types/info.rs Normal file
View file

@ -0,0 +1,49 @@
#[derive(Debug, Clone)]
pub struct MemberInfo {
pub user_id: String,
pub nickname: String,
pub in_voice: bool,
}
#[derive(Debug, Clone)]
pub struct ChannelListItem {
pub channel_id: String,
pub channel_name: String,
pub kind: String,
}
#[derive(Debug, Clone)]
pub struct ChatMsg {
pub message_id: String,
pub sender_id: String,
pub content: String,
pub timestamp: i64,
/// Optional message_id this message is replying to.
pub reply_to: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GuildInfo {
pub guild_id: String,
pub name: String,
pub owner_id: String,
pub icon_url: Option<String>,
pub member_count: u32,
}
#[derive(Debug, Clone)]
pub struct PresenceInfo {
pub user_id: String,
pub status: String,
pub custom_status: Option<String>,
pub activity: Option<String>,
pub activity_text: Option<String>,
}
#[derive(Debug, Clone)]
pub struct FriendInfo {
pub user_id: String,
pub nickname: String,
pub status: String,
pub since: i64,
}

8
src/net/types/mod.rs Normal file
View file

@ -0,0 +1,8 @@
/// Public API types: commands UI→net and events net→UI.
mod command;
mod event;
mod info;
pub use command::NetCommand;
pub use event::NetEvent;
pub use info::{ChannelListItem, ChatMsg, FriendInfo, GuildInfo, MemberInfo, PresenceInfo};

84
src/net/voice/mod.rs Normal file
View file

@ -0,0 +1,84 @@
use super::crypto::SessionCrypto;
use super::payloads::PID_VOICE_PACKET;
mod receive;
pub use receive::spawn_recv;
/// Stable string channel id to u64 key for UDP voice packets.
pub fn channel_key(id: &str) -> u64 {
let mut h: u64 = 5381;
for b in id.bytes() {
h = h.wrapping_mul(33).wrapping_add(u64::from(b));
}
h
}
/// Build a UDP voice packet with encryption.
///
/// Plaintext layout (Phase 1.2+):
/// `[voice_seq:4][timestamp:4][channel_id:8][sender_id:32][opus_data:N]`
///
/// `sender_id` is the raw Ed25519 public key of the sender, so receivers can
/// attribute the voice activity to a specific user (Phase 1.3 speaking indicators).
pub fn build_packet(
channel_id: u64,
voice_seq: u32,
timestamp: u32,
opus: &[u8],
crypto: &SessionCrypto,
packet_seq: u64,
sender_pubkey: &[u8; 32],
) -> Vec<u8> {
let mut plaintext = Vec::with_capacity(16 + 32 + opus.len());
plaintext.extend_from_slice(&voice_seq.to_be_bytes());
plaintext.extend_from_slice(&timestamp.to_be_bytes());
plaintext.extend_from_slice(&channel_id.to_be_bytes());
plaintext.extend_from_slice(sender_pubkey);
plaintext.extend_from_slice(opus);
let encrypted = crypto.encrypt_c2s(packet_seq, &plaintext);
let mut buf = Vec::with_capacity(4 + encrypted.len());
buf.extend_from_slice(&PID_VOICE_PACKET.to_be_bytes());
buf.extend_from_slice(&0u16.to_be_bytes());
buf.extend_from_slice(&encrypted);
buf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_key_is_stable() {
assert_eq!(channel_key("voice"), channel_key("voice"));
assert_ne!(channel_key("voice"), channel_key("general"));
}
#[test]
fn build_packet_header_layout() {
let key = channel_key("voice");
let shared = [0u8; 32];
let crypto = SessionCrypto::derive(&shared, "test");
let sender = [0xABu8; 32];
let pkt = build_packet(key, 42, 1000, b"opus", &crypto, 0, &sender);
let packet_id = u16::from_be_bytes([pkt[0], pkt[1]]);
assert_eq!(packet_id, PID_VOICE_PACKET);
let encrypted_payload = &pkt[4..];
let plaintext = crypto.decrypt_c2s(0, encrypted_payload).unwrap();
assert_eq!(plaintext.len(), 16 + 32 + 4);
let pkt_voice_seq = u32::from_be_bytes(plaintext[0..4].try_into().unwrap());
let timestamp = u32::from_be_bytes(plaintext[4..8].try_into().unwrap());
let channel_id = u64::from_be_bytes(plaintext[8..16].try_into().unwrap());
let sender_id: [u8; 32] = plaintext[16..48].try_into().unwrap();
assert_eq!(pkt_voice_seq, 42);
assert_eq!(timestamp, 1000);
assert_eq!(channel_id, key);
assert_eq!(sender_id, sender);
assert_eq!(&plaintext[48..], b"opus");
}
}

80
src/net/voice/receive.rs Normal file
View file

@ -0,0 +1,80 @@
use std::sync::Arc;
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
use tracing::warn;
use super::super::crypto::SessionCrypto;
use super::super::payloads::PID_VOICE_PACKET;
use super::super::types::NetEvent;
/// Spawn a task that reads UDP datagrams, decrypts them, and forwards as VoicePacket events.
pub fn spawn_recv(udp: Arc<UdpSocket>, crypto: SessionCrypto, tx: mpsc::Sender<NetEvent>) {
tokio::spawn(async move {
let mut buf = vec![0u8; 1472];
let mut voice_seq: u64 = 0;
loop {
match udp.recv_from(&mut buf).await {
Ok((len, src)) => {
if len < 4 {
continue;
}
let packet_id = u16::from_be_bytes([buf[0], buf[1]]);
if packet_id != PID_VOICE_PACKET {
continue;
}
let encrypted_payload = &buf[4..len];
let plaintext = match crypto.decrypt_s2c(voice_seq, encrypted_payload) {
Ok(p) => p,
Err(e) => {
warn!("voice packet decryption failed from {src}: {e}");
voice_seq += 1;
continue;
}
};
// Layout: [voice_seq:4][timestamp:4][channel_id:8][sender_id:32][opus]
// Legacy format (no sender_id) is 16+ bytes — gracefully handle.
if plaintext.len() < 16 {
warn!("voice packet payload too short from {src}");
voice_seq += 1;
continue;
}
let pkt_voice_seq = u32::from_be_bytes(plaintext[0..4].try_into().unwrap());
let timestamp = u32::from_be_bytes(plaintext[4..8].try_into().unwrap());
let channel_id = u64::from_be_bytes(plaintext[8..16].try_into().unwrap());
// sender_id: 32 raw bytes if present, hex-encoded.
// Empty string when packet is from legacy sender without sender_id.
let (sender_id_hex, opus_data) = if plaintext.len() >= 48 {
let sender_bytes: [u8; 32] = plaintext[16..48].try_into().unwrap();
(hex::encode(sender_bytes), plaintext[48..].to_vec())
} else {
// Legacy: no sender_id in payload.
(String::new(), plaintext[16..].to_vec())
};
let _ = tx
.send(NetEvent::VoicePacket {
channel_id,
voice_seq: pkt_voice_seq,
timestamp,
sender: src,
sender_id: sender_id_hex,
opus_data,
})
.await;
voice_seq += 1;
}
Err(e) => {
warn!("UDP recv: {e}");
break;
}
}
}
});
}

51
src/ui/app/frame.rs Normal file
View file

@ -0,0 +1,51 @@
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);
}
}

123
src/ui/app/mod.rs Normal file
View file

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

View file

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

View file

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

View file

@ -0,0 +1,28 @@
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),
}
}

169
src/ui/app/net_poll/mod.rs Normal file
View file

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

View file

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

View file

@ -0,0 +1,69 @@
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,
},
);
}
_ => {}
}
}

50
src/ui/app/setup.rs Normal file
View file

@ -0,0 +1,50 @@
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}"));
}
}
}
}

165
src/ui/chat/dm/messages.rs Normal file
View file

@ -0,0 +1,165 @@
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;
});
}

71
src/ui/chat/dm/mod.rs Normal file
View file

@ -0,0 +1,71 @@
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);
});
}

270
src/ui/chat/friends/list.rs Normal file
View file

@ -0,0 +1,270 @@
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;
});
}
});
});
});
}

209
src/ui/chat/friends/mod.rs Normal file
View file

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

View file

@ -0,0 +1,62 @@
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;
});
}
});
});
});
}

66
src/ui/chat/mod.rs Normal file
View file

@ -0,0 +1,66 @@
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),
);
});
}

186
src/ui/chat/text/input.rs Normal file
View file

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

View file

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

View file

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

View file

@ -0,0 +1,220 @@
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();
}
}
});
}

77
src/ui/chat/text/mod.rs Normal file
View file

@ -0,0 +1,77 @@
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),
);
});
});
});
}

147
src/ui/chat/voice/mod.rs Normal file
View file

@ -0,0 +1,147 @@
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(),
);
});
});
}

199
src/ui/chat/voice/panel.rs Normal file
View file

@ -0,0 +1,199 @@
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()]
}

45
src/ui/connect/action.rs Normal file
View file

@ -0,0 +1,45 @@
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
}
}

130
src/ui/connect/bookmarks.rs Normal file
View file

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

View file

@ -0,0 +1,51 @@
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);
}

149
src/ui/connect/mod.rs Normal file
View file

@ -0,0 +1,149 @@
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);
});
}

133
src/ui/keybind/bindings.rs Normal file
View file

@ -0,0 +1,133 @@
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,
];

114
src/ui/keybind/mod.rs Normal file
View file

@ -0,0 +1,114 @@
//! 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))
}

161
src/ui/members/mod.rs Normal file
View file

@ -0,0 +1,161 @@
//! 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()]
}

12
src/ui/mod.rs Normal file
View file

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

78
src/ui/settings/mod.rs Normal file
View file

@ -0,0 +1,78 @@
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),
}
}

125
src/ui/settings/nav.rs Normal file
View file

@ -0,0 +1,125 @@
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()
}

132
src/ui/settings/network.rs Normal file
View file

@ -0,0 +1,132 @@
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);
});
}

167
src/ui/settings/tabs/app.rs Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,685 @@
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))
}

94
src/ui/sidebar/mod.rs Normal file
View file

@ -0,0 +1,94 @@
//! 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);
}

Some files were not shown because too many files have changed in this diff Show more